1499 lines
62 KiB
JavaScript
1499 lines
62 KiB
JavaScript
/**
|
||
* TalkingQ Platform - 知识库详情页面
|
||
* 包含文件管理、标签管理、切片管理、命中测试等功能
|
||
*
|
||
* API预留:
|
||
* - GET /api/v1/knowledge-bases/:id - 获取知识库详情
|
||
* - GET /api/v1/knowledge-bases/:id/files - 获取文件列表
|
||
* - POST /api/v1/knowledge-bases/:id/files - 上传文件
|
||
* - DELETE /api/v1/knowledge-bases/:id/files/:fileId - 删除文件
|
||
* - POST /api/v1/knowledge-bases/:id/files/:fileId/reparse - 重新解析
|
||
* - GET /api/v1/knowledge-bases/:id/slices - 获取切片列表
|
||
* - PUT /api/v1/knowledge-bases/:id/slices/:sliceId - 更新切片
|
||
* - GET /api/v1/knowledge-bases/:id/hit-test - 命中测试
|
||
* - GET /api/v1/knowledge-bases/:id/metadata - 获取Metadata统计
|
||
*/
|
||
|
||
registerPage('kb-detail', function(container) {
|
||
// ==================== 状态管理 ====================
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const kbId = urlParams.get('id') || 'kb-001';
|
||
const initialTab = urlParams.get('tab') || 'files';
|
||
|
||
let currentTab = initialTab;
|
||
let selectedFiles = new Set();
|
||
let isLoading = true;
|
||
|
||
// ==================== API预留 - 后端接口定义 ====================
|
||
/**
|
||
* 获取知识库详情
|
||
* GET /api/v1/knowledge-bases/:id
|
||
* Response: { id, name, description, type, status, docs, size, tags, createdAt, updatedAt }
|
||
*/
|
||
const API_GET_DETAIL = '/api/v1/knowledge-bases/:id';
|
||
|
||
/**
|
||
* 获取文件列表
|
||
* GET /api/v1/knowledge-bases/:id/files
|
||
* Query: { page, pageSize, search, status }
|
||
* Response: { list: [], total, page, pageSize }
|
||
*/
|
||
const API_GET_FILES = '/api/v1/knowledge-bases/:id/files';
|
||
|
||
/**
|
||
* 上传文件
|
||
* POST /api/v1/knowledge-bases/:id/files
|
||
* FormData: { file, category }
|
||
* Response: { fileId, fileName, status }
|
||
*/
|
||
const API_UPLOAD_FILE = '/api/v1/knowledge-bases/:id/files';
|
||
|
||
/**
|
||
* 删除文件
|
||
* DELETE /api/v1/knowledge-bases/:id/files/:fileId
|
||
* Response: { success: true }
|
||
*/
|
||
const API_DELETE_FILE = '/api/v1/knowledge-bases/:id/files/:fileId';
|
||
|
||
/**
|
||
* 重新解析文件
|
||
* POST /api/v1/knowledge-bases/:id/files/:fileId/reparse
|
||
* Response: { success: true }
|
||
*/
|
||
const API_REPARSE_FILE = '/api/v1/knowledge-bases/:id/files/:fileId/reparse';
|
||
|
||
/**
|
||
* 获取切片列表
|
||
* GET /api/v1/knowledge-bases/:id/slices
|
||
* Query: { page, pageSize, search, fileId }
|
||
* Response: { list: [], total, page, pageSize }
|
||
*/
|
||
const API_GET_SLICES = '/api/v1/knowledge-bases/:id/slices';
|
||
|
||
/**
|
||
* 更新切片
|
||
* PUT /api/v1/knowledge-bases/:id/slices/:sliceId
|
||
* Body: { content: string }
|
||
* Response: { success: true, chars: number }
|
||
*/
|
||
const API_UPDATE_SLICE = '/api/v1/knowledge-bases/:id/slices/:sliceId';
|
||
|
||
/**
|
||
* 获取切片历史版本
|
||
* GET /api/v1/knowledge-bases/:id/slices/:sliceId/history
|
||
* Response: { list: [{ version, content, updatedAt, updatedBy }] }
|
||
*/
|
||
const API_SLICE_HISTORY = '/api/v1/knowledge-bases/:id/slices/:sliceId/history';
|
||
|
||
/**
|
||
* 回滚切片
|
||
* POST /api/v1/knowledge-bases/:id/slices/:sliceId/rollback
|
||
* Body: { version: number }
|
||
* Response: { success: true }
|
||
*/
|
||
const API_ROLLBACK_SLICE = '/api/v1/knowledge-bases/:id/slices/:sliceId/rollback';
|
||
|
||
/**
|
||
* 命中测试
|
||
* GET /api/v1/knowledge-bases/:id/hit-test
|
||
* Query: { query, topK, similarityThreshold, mode }
|
||
* Response: { results: [{ content, source, score, chars, metadata }] }
|
||
*/
|
||
const API_HIT_TEST = '/api/v1/knowledge-bases/:id/hit-test';
|
||
|
||
/**
|
||
* 获取测试历史
|
||
* GET /api/v1/knowledge-bases/:id/test-history
|
||
* Response: { list: [{ id, time, query, recallCount, avgScore }] }
|
||
*/
|
||
const API_TEST_HISTORY = '/api/v1/knowledge-bases/:id/test-history';
|
||
|
||
/**
|
||
* 获取Metadata统计
|
||
* GET /api/v1/knowledge-bases/:id/metadata
|
||
* Response: { tags: [], categories: [], formats: [] }
|
||
*/
|
||
const API_GET_METADATA = '/api/v1/knowledge-bases/:id/metadata';
|
||
|
||
/**
|
||
* 重建索引
|
||
* POST /api/v1/knowledge-bases/:id/rebuild
|
||
* Response: { success: true, taskId }
|
||
*/
|
||
const API_REBUILD_INDEX = '/api/v1/knowledge-bases/:id/rebuild';
|
||
|
||
/**
|
||
* 删除知识库
|
||
* DELETE /api/v1/knowledge-bases/:id
|
||
* Response: { success: true }
|
||
*/
|
||
const API_DELETE_KB = '/api/v1/knowledge-bases/:id';
|
||
|
||
// ==================== 模拟数据 ====================
|
||
const kbData = {
|
||
id: kbId,
|
||
name: '儿童科学百科',
|
||
description: '包含各类儿童科学知识的百科全书,包括物理、化学、生物等基础科学知识',
|
||
type: 'doc_search',
|
||
typeName: '文档搜索',
|
||
tags: ['儿童', '科学', '教育'],
|
||
docs: 2840,
|
||
size: '128MB',
|
||
status: 'ready',
|
||
createdAt: '2026-05-01 10:00:00',
|
||
updatedAt: '2026-05-10 14:32:15',
|
||
};
|
||
|
||
let kbFiles = [
|
||
{ id: 'f1', name: '儿童科学百科.pdf', size: '2.5MB', format: 'PDF', dataSize: '2.5MB', status: 'ready', category: '默认类目', indexedAt: '2026-05-10 14:32:15', slices: 128, chars: 45678, parseError: null },
|
||
{ id: 'f2', name: '科学小实验.docx', size: '1.8MB', format: 'DOCX', dataSize: '1.8MB', status: 'processing', category: '教学类目', indexedAt: '-', slices: 45, chars: 12340, parseError: null },
|
||
{ id: 'f3', name: '物理入门.xlsx', size: '856KB', format: 'XLSX', dataSize: '856KB', status: 'ready', category: '默认类目', indexedAt: '2026-05-09 10:25:30', slices: 89, chars: 34560, parseError: null },
|
||
{ id: 'f4', name: '化学元素表.pdf', size: '3.2MB', format: 'PDF', dataSize: '3.2MB', status: 'error', category: '教学类目', indexedAt: '-', slices: 0, chars: 0, parseError: 'PDF解析失败:文件格式损坏' },
|
||
{ id: 'f5', name: '天文知识.png', size: '5.6MB', format: 'PNG', dataSize: '5.6MB', status: 'ready', category: '默认类目', indexedAt: '2026-05-08 16:45:22', slices: 34, chars: 8900, parseError: null },
|
||
{ id: 'f6', name: '地理常识.docx', size: '2.1MB', format: 'DOCX', dataSize: '2.1MB', status: 'ready', category: '默认类目', indexedAt: '2026-05-07 11:20:15', slices: 67, chars: 23450, parseError: null },
|
||
];
|
||
|
||
const mockSlices = [
|
||
{ id: 's1', content: '光合作用是植物、藻类和某些细菌利用光能将二氧化碳和水转化为有机物并释放氧气的过程。这个过程主要发生在植物叶片的叶绿体中,是地球上最重要的生化反应之一。', source: '儿童科学百科.pdf', chars: 89, score: 0.95, sliceNo: 1, metadata: { title: '第一章 光合作用', author: '张三' } },
|
||
{ id: 's2', content: '植物通过根部的根毛吸收土壤中的水分和无机盐,这些物质通过维管束运输到叶片。叶片中的气孔吸收空气中的二氧化碳,同时释放氧气和水蒸气。', source: '儿童科学百科.pdf', chars: 76, score: 0.88, sliceNo: 2, metadata: { title: '第一章 光合作用' } },
|
||
{ id: 's3', content: '叶绿体是植物细胞中进行光合作用的主要场所,含有叶绿素等色素,赋予植物绿色的外观。叶绿体具有双层膜结构,内部有类囊体堆叠形成的基粒。', source: '儿童科学百科.pdf', chars: 68, score: 0.82, sliceNo: 3, metadata: { title: '第一章 光合作用' } },
|
||
];
|
||
|
||
const testHistory = [
|
||
{ id: 'h1', time: '2026-05-13 14:30:25', query: '光合作用是什么?', recallCount: 3, avgScore: 0.92 },
|
||
{ id: 'h2', time: '2026-05-13 10:15:42', query: '植物如何吸收水分?', recallCount: 2, avgScore: 0.85 },
|
||
{ id: 'h3', time: '2026-05-12 16:45:00', query: '什么是叶绿体?', recallCount: 4, avgScore: 0.78 },
|
||
];
|
||
|
||
const quickQuestions = [
|
||
'什么是光合作用?',
|
||
'植物如何吸收水分?',
|
||
'叶绿体的作用是什么?',
|
||
'光合作用需要什么条件?'
|
||
];
|
||
|
||
// ==================== 工具函数 ====================
|
||
|
||
function getFileIcon(format) {
|
||
const icons = {
|
||
PDF: '📄', DOCX: '📄', DOC: '📄', TXT: '📝', MD: '📋',
|
||
XLSX: '📊', XLS: '📊', CSV: '📊',
|
||
PNG: '🖼', JPG: '🖼', JPEG: '🖼', GIF: '🖼', BMP: '🖼',
|
||
MP4: '🎬', AVI: '🎬', MOV: '🎬', MKV: '🎬', WMV: '🎬'
|
||
};
|
||
return icons[format] || '📁';
|
||
}
|
||
|
||
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 || timeStr === '-') return '-';
|
||
return timeStr;
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function getStatusBadge(status) {
|
||
const map = {
|
||
ready: { label: '解析完成', cls: 'badge-success' },
|
||
processing: { label: '解析中', cls: 'badge-warning' },
|
||
error: { label: '解析失败', cls: 'badge-danger' },
|
||
uploading: { label: '上传中', cls: 'badge-info' },
|
||
};
|
||
return map[status] || { label: status, cls: 'badge-default' };
|
||
}
|
||
|
||
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 formatBytes(bytes) {
|
||
const value = Number(bytes) || 0;
|
||
if (value < 1024) return `${value}B`;
|
||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
|
||
return `${(value / 1024 / 1024).toFixed(1)}MB`;
|
||
}
|
||
|
||
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 renderPage() {
|
||
container.innerHTML = `
|
||
<div class="page-container" style="padding:0">
|
||
<!-- 面包屑 -->
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:20px;font-size:13px;color:var(--text-secondary)">
|
||
<button class="page-back-btn" onclick="navigateTo('knowledge-base')" style="padding:4px 8px">
|
||
<i data-lucide="arrow-left" style="width:14px;height:14px"></i>
|
||
</button>
|
||
<span>/</span>
|
||
<a href="#" onclick="navigateTo('knowledge-base');return false" style="color:var(--text-secondary)">知识库管理</a>
|
||
<span>/</span>
|
||
<span style="color:var(--text-primary)">${kbData.name}</span>
|
||
</div>
|
||
|
||
<!-- 详情头部 -->
|
||
<div class="kb-detail-header">
|
||
<div class="kb-detail-info">
|
||
<div class="kb-detail-title">
|
||
<i data-lucide="${getTypeIcon(kbData.type)}" style="width:24px;height:24px;color:var(--primary)"></i>
|
||
${kbData.name}
|
||
<span class="kb-status-badge ${kbData.status === 'ready' ? 'ready' : kbData.status === 'processing' ? 'processing' : 'error'}">
|
||
<span class="status-dot"></span>
|
||
${kbData.status === 'ready' ? '已就绪' : kbData.status === 'processing' ? '处理中' : '异常'}
|
||
</span>
|
||
</div>
|
||
<div class="kb-detail-desc">${kbData.description}</div>
|
||
<div class="kb-detail-meta">
|
||
<span><i data-lucide="hash" style="width:12px;height:12px"></i> ID: ${kbData.id}</span>
|
||
<span><i data-lucide="layers" style="width:12px;height:12px"></i> 类型: ${kbData.typeName}</span>
|
||
<span><i data-lucide="file-text" style="width:12px;height:12px"></i> 文档: ${kbData.docs.toLocaleString()}</span>
|
||
<span><i data-lucide="database" style="width:12px;height:12px"></i> 数据量: ${kbData.size}</span>
|
||
<span><i data-lucide="calendar" style="width:12px;height:12px"></i> 更新: ${formatTime(kbData.updatedAt)}</span>
|
||
</div>
|
||
<div class="kb-detail-meta" style="margin-top:8px">
|
||
<i data-lucide="tag" style="width:12px;height:12px"></i>
|
||
${kbData.tags.map(t => `<span class="tag">${t}</span>`).join('')}
|
||
<button class="btn btn-ghost btn-sm" onclick="window.__editTags()" style="margin-left:8px">
|
||
<i data-lucide="edit-2" style="width:12px;height:12px"></i>
|
||
编辑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="kb-detail-actions">
|
||
<button class="btn btn-primary" onclick="window.__showHitTest()">
|
||
<i data-lucide="search" style="width:14px;height:14px"></i>
|
||
命中测试
|
||
</button>
|
||
<button class="btn btn-outline" onclick="window.__editKb()">
|
||
<i data-lucide="settings" style="width:14px;height:14px"></i>
|
||
编辑
|
||
</button>
|
||
<div class="dropdown">
|
||
<button class="btn btn-ghost" onclick="window.__toggleDetailDropdown(this, event)">
|
||
<i data-lucide="more-horizontal" style="width:14px;height:14px"></i>
|
||
</button>
|
||
<div class="dropdown-menu" style="display:none">
|
||
<div class="dropdown-item" onclick="window.__rebuildAllIndex()">
|
||
<i data-lucide="refresh-cw" style="width:14px;height:14px"></i> 重建全部索引
|
||
</div>
|
||
<div class="dropdown-item" onclick="window.__reparseAll()">
|
||
<i data-lucide="file-search" style="width:14px;height:14px"></i> 重新解析全部
|
||
</div>
|
||
<div class="dropdown-item" onclick="window.__exportKb()">
|
||
<i data-lucide="download" style="width:14px;height:14px"></i> 导出知识库
|
||
</div>
|
||
<div class="dropdown-divider"></div>
|
||
<div class="dropdown-item danger" onclick="window.__deleteKb()">
|
||
<i data-lucide="trash-2" style="width:14px;height:14px"></i> 删除知识库
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab切换 -->
|
||
<div class="kb-detail-tabs">
|
||
<div class="kb-detail-tab ${currentTab === 'files' ? 'active' : ''}" onclick="window.__switchTab('files')">
|
||
<i data-lucide="folder" style="width:14px;height:14px;margin-right:6px;vertical-align:middle"></i>
|
||
文件管理
|
||
</div>
|
||
<div class="kb-detail-tab ${currentTab === 'tags' ? 'active' : ''}" onclick="window.__switchTab('tags')">
|
||
<i data-lucide="tag" style="width:14px;height:14px;margin-right:6px;vertical-align:middle"></i>
|
||
标签管理
|
||
</div>
|
||
<div class="kb-detail-tab ${currentTab === 'slices' ? 'active' : ''}" onclick="window.__switchTab('slices')">
|
||
<i data-lucide="list" style="width:14px;height:14px;margin-right:6px;vertical-align:middle"></i>
|
||
切片管理
|
||
</div>
|
||
<div class="kb-detail-tab ${currentTab === 'metadata' ? 'active' : ''}" onclick="window.__switchTab('metadata')">
|
||
<i data-lucide="bar-chart-2" style="width:14px;height:14px;margin-right:6px;vertical-align:middle"></i>
|
||
统计信息
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab内容 -->
|
||
<div id="tabContent">
|
||
${currentTab === 'files' ? renderFilesTab() :
|
||
currentTab === 'tags' ? renderTagsTab() :
|
||
currentTab === 'slices' ? renderSlicesTab() :
|
||
renderMetadataTab()}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
lucide.createIcons({ elements: [container] });
|
||
}
|
||
|
||
// ==================== 文件管理Tab ====================
|
||
|
||
function renderFilesTab() {
|
||
const readyCount = kbFiles.filter(f => f.status === 'ready').length;
|
||
const processingCount = kbFiles.filter(f => f.status === 'processing' || f.status === 'uploading').length;
|
||
const errorCount = kbFiles.filter(f => f.status === 'error').length;
|
||
|
||
return `
|
||
<!-- 文件统计 -->
|
||
<div style="display:flex;gap:16px;margin-bottom:20px;flex-wrap:wrap">
|
||
<div style="flex:1;min-width:120px;padding:16px;background:var(--bg-white);border:1px solid var(--border);border-radius:var(--radius);text-align:center">
|
||
<div style="font-size:24px;font-weight:700;color:var(--primary)">${kbFiles.length}</div>
|
||
<div style="font-size:12px;color:var(--text-muted)">总文件数</div>
|
||
</div>
|
||
<div style="flex:1;min-width:120px;padding:16px;background:var(--bg-white);border:1px solid var(--border);border-radius:var(--radius);text-align:center">
|
||
<div style="font-size:24px;font-weight:700;color:var(--success)">${readyCount}</div>
|
||
<div style="font-size:12px;color:var(--text-muted)">已解析</div>
|
||
</div>
|
||
<div style="flex:1;min-width:120px;padding:16px;background:var(--bg-white);border:1px solid var(--border);border-radius:var(--radius);text-align:center">
|
||
<div style="font-size:24px;font-weight:700;color:var(--warning)">${processingCount}</div>
|
||
<div style="font-size:12px;color:var(--text-muted)">解析中</div>
|
||
</div>
|
||
<div style="flex:1;min-width:120px;padding:16px;background:var(--bg-white);border:1px solid var(--border);border-radius:var(--radius);text-align:center">
|
||
<div style="font-size:24px;font-weight:700;color:var(--danger)">${errorCount}</div>
|
||
<div style="font-size:12px;color:var(--text-muted)">解析失败</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 批量操作栏 -->
|
||
<div class="kb-batch-bar ${selectedFiles.size > 0 ? 'visible' : ''}" id="batchBar">
|
||
<div class="kb-batch-info">
|
||
已选择 <strong>${selectedFiles.size}</strong> 个文件
|
||
</div>
|
||
<div class="kb-batch-actions">
|
||
<button class="btn btn-sm btn-outline" onclick="window.__reparseSelectedFiles()">
|
||
<i data-lucide="refresh-cw" style="width:14px;height:14px"></i> 重新解析
|
||
</button>
|
||
<button class="btn btn-sm btn-outline" onclick="window.__moveSelectedFiles()">
|
||
<i data-lucide="folder" style="width:14px;height:14px"></i> 移动类目
|
||
</button>
|
||
<button class="btn btn-sm btn-danger" onclick="window.__deleteSelectedFiles()">
|
||
<i data-lucide="trash-2" style="width:14px;height:14px"></i> 删除
|
||
</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="window.__clearFileSelection()">
|
||
取消选择
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文件操作栏 -->
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:12px">
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn btn-primary btn-sm" onclick="window.__uploadFiles()">
|
||
<i data-lucide="upload" style="width:14px;height:14px"></i>
|
||
上传文件
|
||
</button>
|
||
<button class="btn btn-outline btn-sm" onclick="window.__selectAllFiles()">
|
||
<i data-lucide="check-square" style="width:14px;height:14px"></i>
|
||
全选
|
||
</button>
|
||
</div>
|
||
<div class="filter-search" style="width:280px">
|
||
<i data-lucide="search" style="width:15px;height:15px"></i>
|
||
<input type="text" id="fileSearchInput" placeholder="搜索文件名..." oninput="window.__filterFiles(this.value)">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文件列表 -->
|
||
<div class="kb-table-wrapper">
|
||
<table class="kb-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:40px">
|
||
<input type="checkbox" class="row-checkbox" id="selectAllFiles" onchange="window.__toggleSelectAllFiles(this.checked)">
|
||
</th>
|
||
<th>文件名</th>
|
||
<th style="width:80px">格式</th>
|
||
<th style="width:80px">大小</th>
|
||
<th style="width:80px">数据量</th>
|
||
<th style="width:100px">状态</th>
|
||
<th style="width:120px">所属类目</th>
|
||
<th style="width:80px">切片数</th>
|
||
<th style="width:160px">索引时间</th>
|
||
<th style="width:140px">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="fileTableBody">
|
||
${renderFileRows()}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderFileRows() {
|
||
if (kbFiles.length === 0) {
|
||
return `
|
||
<tr>
|
||
<td colspan="10">
|
||
<div class="kb-empty">
|
||
<div class="kb-empty-icon">
|
||
<i data-lucide="file-x"></i>
|
||
</div>
|
||
<div class="kb-empty-title">暂无文件</div>
|
||
<div class="kb-empty-desc">点击上方「上传文件」添加文件</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
return kbFiles.map(file => `
|
||
<tr class="${selectedFiles.has(file.id) ? 'selected' : ''}" data-id="${file.id}">
|
||
<td>
|
||
<input type="checkbox" class="row-checkbox" ${selectedFiles.has(file.id) ? 'checked' : ''}
|
||
onchange="window.__toggleFileSelect('${file.id}', this.checked)">
|
||
</td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<span style="font-size:20px">${getFileIcon(file.format)}</span>
|
||
<span style="font-weight:500">${file.name}</span>
|
||
</div>
|
||
</td>
|
||
<td style="font-size:12px;color:var(--text-secondary)">${file.format}</td>
|
||
<td style="font-size:12px;color:var(--text-secondary)">${file.size}</td>
|
||
<td style="font-size:12px;color:var(--text-secondary)">${file.dataSize}</td>
|
||
<td>
|
||
<span class="kb-status-badge ${getStatusBadge(file.status).cls.replace('badge-', '')}">
|
||
<span class="status-dot"></span>
|
||
${getStatusBadge(file.status).label}
|
||
</span>
|
||
${file.status === 'error' && file.parseError ? `
|
||
<div style="font-size:11px;color:var(--danger);margin-top:2px;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${file.parseError}">
|
||
${file.parseError}
|
||
</div>
|
||
` : ''}
|
||
</td>
|
||
<td style="font-size:12px;color:var(--text-secondary)">${file.category}</td>
|
||
<td style="font-size:12px">
|
||
${file.slices > 0 ? `<span style="color:var(--primary);cursor:pointer" onclick="window.__viewSlices('${file.id}')">${file.slices}</span>` : '-'}
|
||
</td>
|
||
<td style="font-size:12px;color:var(--text-muted)">${formatTime(file.indexedAt)}</td>
|
||
<td>
|
||
<div class="kb-table-actions">
|
||
<button class="btn btn-ghost btn-sm" data-tooltip="Meta 信息" onclick="window.__viewMetaInfo('${file.id}')">
|
||
<i data-lucide="info" style="width:14px;height:14px"></i>
|
||
</button>
|
||
<div class="dropdown">
|
||
<button class="btn btn-ghost btn-sm" data-tooltip="更多操作" onclick="window.__toggleFileDropdown('${file.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.__reparseFile('${file.id}')">
|
||
<i data-lucide="refresh-cw" style="width:14px;height:14px"></i> 重新解析
|
||
</div>
|
||
<div class="dropdown-item" onclick="event.stopPropagation();window.__moveFile('${file.id}')">
|
||
<i data-lucide="folder" style="width:14px;height:14px"></i> 移动到类目
|
||
</div>
|
||
<div class="dropdown-divider"></div>
|
||
<div class="dropdown-item danger" onclick="event.stopPropagation();window.__deleteFile('${file.id}')">
|
||
<i data-lucide="trash-2" style="width:14px;height:14px"></i> 删除
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
// ==================== 标签管理Tab ====================
|
||
|
||
function renderTagsTab() {
|
||
return `
|
||
<div style="padding:24px;background:var(--bg-white);border-radius:var(--radius-lg);border:1px solid var(--border)">
|
||
<!-- 当前标签 -->
|
||
<div style="margin-bottom:32px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||
<h3 style="font-size:15px;font-weight:600">知识库标签</h3>
|
||
<button class="btn btn-primary btn-sm" onclick="window.__showAddTag()">
|
||
<i data-lucide="plus" style="width:14px;height:14px"></i>
|
||
新增标签
|
||
</button>
|
||
</div>
|
||
<div class="tag-list">
|
||
${kbData.tags.map((tag, idx) => `
|
||
<div class="tag-item">
|
||
${tag}
|
||
<span class="remove-tag" onclick="window.__removeTag(${idx})">
|
||
<i data-lucide="x" style="width:10px;height:10px"></i>
|
||
</span>
|
||
</div>
|
||
`).join('')}
|
||
<div class="add-tag-input">
|
||
<input type="text" placeholder="输入新标签..." id="newTagInput" maxlength="32" style="outline:none"
|
||
onkeypress="if(event.key==='Enter')window.__addNewTag()">
|
||
</div>
|
||
</div>
|
||
<div class="form-hint">
|
||
<i data-lucide="info" style="width:12px;height:12px;vertical-align:middle"></i>
|
||
标签字数限制:0-32 个字符,支持中英文、数字
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 标签使用统计 -->
|
||
<div style="border-top:1px solid var(--border);padding-top:24px">
|
||
<h3 style="font-size:15px;font-weight:600;margin-bottom:16px">标签使用统计</h3>
|
||
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||
${kbData.tags.map(tag => `
|
||
<div style="padding:8px 16px;background:var(--bg-base);border-radius:var(--radius);display:flex;align-items:center;gap:8px">
|
||
<span class="tag">${tag}</span>
|
||
<span style="font-size:12px;color:var(--text-muted)">${Math.floor(Math.random() * 100 + 50)} 个文件</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ==================== 切片管理Tab ====================
|
||
|
||
function renderSlicesTab() {
|
||
return `
|
||
<div style="background:var(--bg-white);border-radius:var(--radius-lg);border:1px solid var(--border);padding:20px">
|
||
<!-- 切片操作栏 -->
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:12px">
|
||
<div style="display:flex;gap:8px">
|
||
<span style="font-size:13px;color:var(--text-muted)">
|
||
共 <strong>${kbData.docs}</strong> 个切片
|
||
</span>
|
||
</div>
|
||
<div class="filter-search" style="width:280px">
|
||
<i data-lucide="search" style="width:15px;height:15px"></i>
|
||
<input type="text" id="sliceSearchInput" placeholder="搜索切片内容..." oninput="window.__filterSlices(this.value)">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 切片列表 -->
|
||
<div class="slice-list" id="sliceList">
|
||
${mockSlices.map((slice, idx) => `
|
||
<div class="slice-list-item" style="cursor:pointer" onclick="window.__editSlice('${slice.id}')">
|
||
<div class="slice-list-header">
|
||
<span>
|
||
<strong>切片 #${slice.sliceNo}</strong>
|
||
<span style="margin-left:12px;font-size:12px;color:var(--text-muted)">
|
||
来源: ${slice.source} · ${slice.chars} 字符
|
||
</span>
|
||
</span>
|
||
<div style="display:flex;gap:4px;align-items:center">
|
||
<span class="kb-status-badge ready" style="font-size:11px;padding:2px 8px">
|
||
相关度: ${(slice.score * 100).toFixed(0)}%
|
||
</span>
|
||
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();window.__editSlice('${slice.id}')">
|
||
<i data-lucide="edit" style="width:14px;height:14px"></i> 编辑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="slice-list-content">${slice.content}</div>
|
||
${slice.metadata ? `
|
||
<div style="margin-top:8px;font-size:12px;color:var(--text-muted)">
|
||
<i data-lucide="tag" style="width:12px;height:12px;vertical-align:middle"></i>
|
||
${Object.entries(slice.metadata).map(([k,v]) => `${k}: ${v}`).join(' | ')}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<div class="kb-pagination" style="margin-top:16px">
|
||
<div class="kb-pagination-info">共 ${kbData.docs} 个切片</div>
|
||
<div class="kb-pagination-controls">
|
||
<button class="kb-pagination-btn" disabled><i data-lucide="chevron-left" style="width:14px;height:14px"></i></button>
|
||
<button class="kb-pagination-btn active" onclick="Toast.info('当前已是第 1 页')">1</button>
|
||
<button class="kb-pagination-btn" onclick="Toast.info('切换到第 2 页')">2</button>
|
||
<button class="kb-pagination-btn" onclick="Toast.info('切换到第 3 页')">3</button>
|
||
<span style="padding:0 4px;color:var(--text-muted)">...</span>
|
||
<button class="kb-pagination-btn" onclick="Toast.info('切换到末页')">${Math.ceil(kbData.docs / 10)}</button>
|
||
<button class="kb-pagination-btn" onclick="Toast.info('切换到下一页')"><i data-lucide="chevron-right" style="width:14px;height:14px"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ==================== 统计信息Tab ====================
|
||
|
||
function renderMetadataTab() {
|
||
return `
|
||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px">
|
||
<!-- 切片统计 -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">切片统计</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:12px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span style="font-size:13px;color:var(--text-secondary)">总切片数</span>
|
||
<span style="font-size:18px;font-weight:600;color:var(--primary)">${kbData.docs.toLocaleString()}</span>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span style="font-size:13px;color:var(--text-secondary)">总字符数</span>
|
||
<span style="font-size:18px;font-weight:600">1,245,678</span>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span style="font-size:13px;color:var(--text-secondary)">平均切片长度</span>
|
||
<span style="font-size:18px;font-weight:600">439 字符</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文件格式分布 -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">文件格式分布</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:12px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span style="font-size:13px;color:var(--text-secondary)">PDF</span>
|
||
<span style="font-size:14px;font-weight:500">45%</span>
|
||
</div>
|
||
<div class="progress-bar"><div class="progress-fill" style="width:45%"></div></div>
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span style="font-size:13px;color:var(--text-secondary)">DOCX</span>
|
||
<span style="font-size:14px;font-weight:500">30%</span>
|
||
</div>
|
||
<div class="progress-bar"><div class="progress-fill" style="width:30%"></div></div>
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span style="font-size:13px;color:var(--text-secondary)">XLSX</span>
|
||
<span style="font-size:14px;font-weight:500">15%</span>
|
||
</div>
|
||
<div class="progress-bar"><div class="progress-fill" style="width:15%"></div></div>
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span style="font-size:13px;color:var(--text-secondary)">其他</span>
|
||
<span style="font-size:14px;font-weight:500">10%</span>
|
||
</div>
|
||
<div class="progress-bar"><div class="progress-fill" style="width:10%"></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Metadata统计 -->
|
||
<div class="card" style="grid-column:span 2">
|
||
<div class="card-header">
|
||
<span class="card-title">Metadata 统计</span>
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:16px">
|
||
<div style="flex:1;min-width:200px">
|
||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">提取的标题</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||
${['第一章 光合作用', '第二章 呼吸作用', '第三章 物质运输', '第四章 生长素'].map(t => `
|
||
<span style="padding:4px 10px;background:var(--bg-base);border-radius:4px;font-size:12px">${t}</span>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
<div style="flex:1;min-width:200px">
|
||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">关键词</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||
${['光合作用', '叶绿体', '氧气', '二氧化碳', '水分'].map(k => `
|
||
<span style="padding:4px 10px;background:var(--primary-bg);color:var(--primary);border-radius:4px;font-size:12px">${k}</span>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ==================== 全局函数 ====================
|
||
|
||
let __dropdownHandler = null;
|
||
|
||
window.__switchTab = function(tab) {
|
||
currentTab = tab;
|
||
renderPage();
|
||
};
|
||
|
||
window.__toggleDetailDropdown = function(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', __dropdownHandler);
|
||
|
||
if (!isOpen) {
|
||
dropdown.classList.add('open');
|
||
menu.style.display = 'block';
|
||
__dropdownHandler = (ev) => {
|
||
if (!dropdown.contains(ev.target)) {
|
||
dropdown.classList.remove('open');
|
||
menu.style.display = 'none';
|
||
document.removeEventListener('click', __dropdownHandler);
|
||
}
|
||
};
|
||
setTimeout(() => document.addEventListener('click', __dropdownHandler), 10);
|
||
}
|
||
};
|
||
|
||
window.__toggleFileDropdown = function(id, btn, e) {
|
||
window.__toggleDetailDropdown(btn, e);
|
||
};
|
||
|
||
// 文件选择
|
||
window.__toggleFileSelect = function(id, checked) {
|
||
if (checked) selectedFiles.add(id);
|
||
else selectedFiles.delete(id);
|
||
renderPage();
|
||
};
|
||
|
||
window.__toggleSelectAllFiles = function(checked) {
|
||
if (checked) kbFiles.forEach(f => selectedFiles.add(f.id));
|
||
else selectedFiles.clear();
|
||
renderPage();
|
||
};
|
||
|
||
window.__clearFileSelection = function() {
|
||
selectedFiles.clear();
|
||
renderPage();
|
||
};
|
||
|
||
window.__selectAllFiles = function() {
|
||
kbFiles.forEach(f => selectedFiles.add(f.id));
|
||
renderPage();
|
||
};
|
||
|
||
window.__filterFiles = function(keyword) {
|
||
const tbody = document.getElementById('fileTableBody');
|
||
if (!tbody) return;
|
||
tbody.querySelectorAll('tr').forEach(row => {
|
||
const name = row.querySelector('td:nth-child(2)')?.textContent.toLowerCase() || '';
|
||
row.style.display = name.includes(keyword.toLowerCase()) ? '' : 'none';
|
||
});
|
||
};
|
||
|
||
window.__filterSlices = function(keyword) {
|
||
const query = String(keyword || '').trim().toLowerCase();
|
||
document.querySelectorAll('#sliceList .slice-list-item').forEach(item => {
|
||
const text = item.textContent.toLowerCase();
|
||
item.style.display = text.includes(query) ? '' : 'none';
|
||
});
|
||
};
|
||
|
||
// 文件操作
|
||
window.__uploadFiles = function() {
|
||
createModal({
|
||
title: '上传文件',
|
||
size: 'lg',
|
||
content: `
|
||
<div class="form-group">
|
||
<label class="form-label">选择文件</label>
|
||
<input type="file" class="form-control" id="detailUploadInput" multiple>
|
||
<div class="form-hint">前端 mock 会将文件加入列表并进入“解析中”状态;后端接入后调用 POST /api/v1/knowledge-bases/:id/files。</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">所属类目</label>
|
||
<select class="form-control" id="detailUploadCategory">
|
||
<option value="默认类目">默认类目</option>
|
||
<option value="教学类目">教学类目</option>
|
||
<option value="产品文档">产品文档</option>
|
||
<option value="用户指南">用户指南</option>
|
||
</select>
|
||
</div>
|
||
`,
|
||
confirmText: '上传',
|
||
onConfirm: () => {
|
||
const input = document.getElementById('detailUploadInput');
|
||
const files = Array.from(input?.files || []);
|
||
if (!files.length) {
|
||
Toast.warning('请先选择文件');
|
||
return false;
|
||
}
|
||
const category = document.getElementById('detailUploadCategory')?.value || '默认类目';
|
||
files.forEach(file => {
|
||
const ext = file.name.split('.').pop()?.toUpperCase() || 'FILE';
|
||
kbFiles.unshift({
|
||
id: `f-${Date.now()}-${Math.random().toString(16).slice(2, 7)}`,
|
||
name: file.name,
|
||
size: formatBytes(file.size),
|
||
format: ext,
|
||
dataSize: formatBytes(file.size),
|
||
status: 'processing',
|
||
category,
|
||
indexedAt: '-',
|
||
slices: 0,
|
||
chars: 0,
|
||
parseError: null,
|
||
});
|
||
});
|
||
kbData.docs += files.length;
|
||
kbData.updatedAt = getNowText();
|
||
selectedFiles.clear();
|
||
renderPage();
|
||
Toast.success(`已上传 ${files.length} 个文件`);
|
||
}
|
||
});
|
||
};
|
||
|
||
window.__viewMetaInfo = function(fileId) {
|
||
const file = kbFiles.find(f => f.id === fileId);
|
||
createModal({
|
||
title: 'Meta 信息',
|
||
content: `
|
||
<table class="meta-info-table">
|
||
<tr><td>文件名</td><td>${file?.name || '-'}</td></tr>
|
||
<tr><td>文件大小</td><td>${file?.size || '-'}</td></tr>
|
||
<tr><td>数据量</td><td>${file?.dataSize || '-'}</td></tr>
|
||
<tr><td>文件格式</td><td>${file?.format || '-'}</td></tr>
|
||
<tr><td>切片数量</td><td>${file?.slices || 0} 个</td></tr>
|
||
<tr><td>字符总数</td><td>${file?.chars?.toLocaleString() || 0} 字符</td></tr>
|
||
<tr><td>状态</td><td>${getStatusBadge(file?.status).label}</td></tr>
|
||
<tr><td>所属类目</td><td>${file?.category || '-'}</td></tr>
|
||
<tr><td>索引时间</td><td>${formatTime(file?.indexedAt)}</td></tr>
|
||
</table>
|
||
`,
|
||
confirmText: '',
|
||
cancelText: '关闭'
|
||
});
|
||
};
|
||
|
||
window.__viewSlices = function(fileId) {
|
||
const file = kbFiles.find(f => f.id === fileId);
|
||
createModal({
|
||
title: `查看切片 - ${file?.name || ''}`,
|
||
size: 'lg',
|
||
content: `
|
||
<div style="margin-bottom:16px;padding:12px;background:var(--bg-base);border-radius:var(--radius);font-size:13px">
|
||
<div style="display:flex;justify-content:space-between">
|
||
<span>切片数量: <strong>${file?.slices || 0}</strong></span>
|
||
<span>总字符数: <strong>${file?.chars?.toLocaleString() || 0}</strong></span>
|
||
</div>
|
||
</div>
|
||
<div class="slice-list">
|
||
${mockSlices.map((slice, idx) => `
|
||
<div class="slice-list-item">
|
||
<div class="slice-list-header">
|
||
<span>
|
||
<strong>切片 ${slice.sliceNo}</strong>
|
||
<span style="margin-left:12px;font-size:12px;color:var(--text-muted)">${slice.chars} 字符</span>
|
||
</span>
|
||
<div style="display:flex;gap:4px">
|
||
<button class="btn btn-ghost btn-sm" onclick="window.__editSlice('${slice.id}')">
|
||
<i data-lucide="edit" style="width:14px;height:14px"></i> 编辑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="slice-list-content">${slice.content}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`,
|
||
confirmText: '',
|
||
cancelText: '关闭'
|
||
});
|
||
lucide.createIcons();
|
||
};
|
||
|
||
window.__reparseFile = function(fileId) {
|
||
const file = kbFiles.find(f => f.id === fileId);
|
||
createModal({
|
||
title: '重新解析',
|
||
content: `
|
||
<p>确定要重新解析文件「${file?.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/files/:fileId/reparse
|
||
const idx = kbFiles.findIndex(f => f.id === fileId);
|
||
if (idx !== -1) {
|
||
kbFiles[idx].status = 'processing';
|
||
renderPage();
|
||
}
|
||
Toast.success('重新解析任务已启动');
|
||
}
|
||
});
|
||
};
|
||
|
||
window.__moveFile = function(fileId) {
|
||
const file = kbFiles.find(f => f.id === fileId);
|
||
if (!file) {
|
||
Toast.warning('未找到文件');
|
||
return;
|
||
}
|
||
createModal({
|
||
title: '移动到类目',
|
||
content: `
|
||
<p style="margin-bottom:12px">文件:<strong>${file.name}</strong></p>
|
||
<div class="form-group">
|
||
<label class="form-label">目标类目</label>
|
||
<select class="form-control" id="moveFileCategory">
|
||
${['默认类目', '教学类目', '产品文档', '用户指南'].map(category => `
|
||
<option value="${category}" ${file.category === category ? 'selected' : ''}>${category}</option>
|
||
`).join('')}
|
||
</select>
|
||
</div>
|
||
`,
|
||
confirmText: '移动',
|
||
onConfirm: () => {
|
||
file.category = document.getElementById('moveFileCategory')?.value || file.category;
|
||
renderPage();
|
||
Toast.success('文件已移动');
|
||
}
|
||
});
|
||
};
|
||
|
||
window.__deleteFile = function(fileId) {
|
||
const file = kbFiles.find(f => f.id === fileId);
|
||
createModal({
|
||
title: '删除文件',
|
||
content: `
|
||
<p>确定要删除文件「${file?.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>此操作不可恢复,文件及其切片数据将被永久删除。</div>
|
||
</div>
|
||
`,
|
||
confirmText: '删除',
|
||
confirmClass: 'btn-danger',
|
||
onConfirm: () => {
|
||
kbFiles = kbFiles.filter(f => f.id !== fileId);
|
||
renderPage();
|
||
Toast.success('文件已删除');
|
||
}
|
||
});
|
||
lucide.createIcons();
|
||
};
|
||
|
||
// 批量操作
|
||
window.__reparseSelectedFiles = function() {
|
||
if (selectedFiles.size === 0) {
|
||
Toast.warning('请先选择文件');
|
||
return;
|
||
}
|
||
createModal({
|
||
title: '重新解析',
|
||
content: `<p>确定要重新解析选中的 <strong>${selectedFiles.size}</strong> 个文件吗?</p>`,
|
||
confirmText: '重新解析',
|
||
onConfirm: () => {
|
||
// TODO: 调用批量重新解析API
|
||
selectedFiles.forEach(id => {
|
||
const idx = kbFiles.findIndex(f => f.id === id);
|
||
if (idx !== -1) kbFiles[idx].status = 'processing';
|
||
});
|
||
selectedFiles.clear();
|
||
renderPage();
|
||
Toast.success('重新解析任务已启动');
|
||
}
|
||
});
|
||
};
|
||
|
||
window.__moveSelectedFiles = function() {
|
||
if (selectedFiles.size === 0) {
|
||
Toast.warning('请先选择文件');
|
||
return;
|
||
}
|
||
createModal({
|
||
title: '批量移动类目',
|
||
content: `
|
||
<p style="margin-bottom:12px">将选中的 <strong>${selectedFiles.size}</strong> 个文件移动到:</p>
|
||
<div class="form-group">
|
||
<label class="form-label">目标类目</label>
|
||
<select class="form-control" id="moveSelectedCategory">
|
||
<option value="默认类目">默认类目</option>
|
||
<option value="教学类目">教学类目</option>
|
||
<option value="产品文档">产品文档</option>
|
||
<option value="用户指南">用户指南</option>
|
||
</select>
|
||
</div>
|
||
`,
|
||
confirmText: '移动',
|
||
onConfirm: () => {
|
||
const category = document.getElementById('moveSelectedCategory')?.value || '默认类目';
|
||
selectedFiles.forEach(id => {
|
||
const file = kbFiles.find(f => f.id === id);
|
||
if (file) file.category = category;
|
||
});
|
||
selectedFiles.clear();
|
||
renderPage();
|
||
Toast.success('文件已移动');
|
||
}
|
||
});
|
||
};
|
||
|
||
window.__deleteSelectedFiles = function() {
|
||
if (selectedFiles.size === 0) {
|
||
Toast.warning('请先选择文件');
|
||
return;
|
||
}
|
||
createModal({
|
||
title: '批量删除',
|
||
content: `
|
||
<p>确定要删除选中的 <strong>${selectedFiles.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>此操作不可恢复。</div>
|
||
</div>
|
||
`,
|
||
confirmText: '删除',
|
||
confirmClass: 'btn-danger',
|
||
onConfirm: () => {
|
||
kbFiles = kbFiles.filter(f => !selectedFiles.has(f.id));
|
||
selectedFiles.clear();
|
||
renderPage();
|
||
Toast.success('文件已删除');
|
||
}
|
||
});
|
||
};
|
||
|
||
// 切片编辑
|
||
window.__editSlice = function(sliceId) {
|
||
const slice = mockSlices.find(s => s.id === sliceId);
|
||
createModal({
|
||
title: '编辑切片',
|
||
size: 'lg',
|
||
content: `
|
||
<div class="slice-edit-content">
|
||
<div style="margin-bottom:8px;font-size:13px;color:var(--text-secondary)">
|
||
来源: ${slice?.source} · 切片 ${slice?.sliceNo} · <span id="charCount">${slice?.chars}</span> 字符
|
||
</div>
|
||
<textarea class="form-control" id="sliceEditor" rows="8" style="font-family:monospace;resize:vertical">${slice?.content || ''}</textarea>
|
||
<div class="slice-edit-footer">
|
||
<span>支持编辑切片内容</span>
|
||
<span>字符数将自动更新</span>
|
||
</div>
|
||
<div style="margin-top:12px">
|
||
<button class="btn btn-ghost btn-sm" onclick="window.__showSliceHistory('${sliceId}')">
|
||
<i data-lucide="history" style="width:14px;height:14px"></i>
|
||
查看历史版本
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`,
|
||
confirmText: '保存',
|
||
onConfirm: () => {
|
||
const newContent = document.getElementById('sliceEditor')?.value || '';
|
||
const idx = mockSlices.findIndex(s => s.id === sliceId);
|
||
if (idx !== -1) {
|
||
mockSlices[idx].content = newContent;
|
||
mockSlices[idx].chars = newContent.length;
|
||
}
|
||
Toast.success('切片已保存');
|
||
}
|
||
});
|
||
|
||
setTimeout(() => {
|
||
const editor = document.getElementById('sliceEditor');
|
||
const counter = document.getElementById('charCount');
|
||
if (editor && counter) {
|
||
editor.addEventListener('input', () => {
|
||
counter.textContent = editor.value.length;
|
||
});
|
||
}
|
||
}, 100);
|
||
};
|
||
|
||
window.__showSliceHistory = function(sliceId) {
|
||
createModal({
|
||
title: '切片历史版本',
|
||
content: `
|
||
<div style="max-height:300px;overflow-y:auto">
|
||
${[1,2,3].map(v => `
|
||
<div style="padding:12px;border-bottom:1px solid var(--border);cursor:pointer" onclick="window.__rollbackSlice('${sliceId}', ${v})">
|
||
<div style="font-size:13px;font-weight:500">版本 ${v}</div>
|
||
<div style="font-size:12px;color:var(--text-muted)">2026-05-${10-v} 14:30:25 · 张三</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`,
|
||
confirmText: '',
|
||
cancelText: '关闭'
|
||
});
|
||
};
|
||
|
||
window.__rollbackSlice = function(sliceId, version) {
|
||
Toast.success(`已回滚到版本 ${version}`);
|
||
};
|
||
|
||
// 标签管理
|
||
window.__showAddTag = function() {
|
||
createModal({
|
||
title: '新增标签',
|
||
content: `
|
||
<div class="form-group">
|
||
<label class="form-label">标签名称</label>
|
||
<input type="text" class="form-control" id="newTagName" placeholder="请输入标签名称" maxlength="32">
|
||
<div class="char-counter" id="tagCharCounter">0/32</div>
|
||
</div>
|
||
<div class="form-hint">
|
||
<i data-lucide="info" style="width:12px;height:12px;vertical-align:middle"></i>
|
||
标签字数限制:0-32 个字符
|
||
</div>
|
||
`,
|
||
confirmText: '添加',
|
||
onConfirm: () => {
|
||
const name = document.getElementById('newTagName')?.value.trim();
|
||
if (name) {
|
||
kbData.tags.push(name);
|
||
renderPage();
|
||
Toast.success('标签已添加');
|
||
}
|
||
}
|
||
});
|
||
|
||
setTimeout(() => {
|
||
const input = document.getElementById('newTagName');
|
||
const counter = document.getElementById('tagCharCounter');
|
||
if (input && counter) {
|
||
input.addEventListener('input', () => {
|
||
counter.textContent = `${input.value.length}/32`;
|
||
});
|
||
}
|
||
}, 100);
|
||
};
|
||
|
||
window.__addNewTag = function() {
|
||
const input = document.getElementById('newTagInput');
|
||
if (input?.value.trim()) {
|
||
kbData.tags.push(input.value.trim());
|
||
input.value = '';
|
||
renderPage();
|
||
Toast.success('标签已添加');
|
||
}
|
||
};
|
||
|
||
window.__removeTag = function(index) {
|
||
kbData.tags.splice(index, 1);
|
||
renderPage();
|
||
Toast.success('标签已移除');
|
||
};
|
||
|
||
window.__editTags = function() {
|
||
currentTab = 'tags';
|
||
renderPage();
|
||
};
|
||
|
||
// 命中测试
|
||
window.__showHitTest = function() {
|
||
createModal({
|
||
title: '命中率测试',
|
||
size: 'lg',
|
||
content: `
|
||
<div class="hit-test-panel">
|
||
<!-- 测试参数 -->
|
||
<div style="display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap">
|
||
<div style="flex:1;min-width:120px">
|
||
<label style="font-size:12px;color:var(--text-muted);display:block;margin-bottom:4px">排序模式</label>
|
||
<select class="form-control" id="testRerankMode">
|
||
<option value="qa">问答模式</option>
|
||
<option value="similarity">相似模式</option>
|
||
</select>
|
||
</div>
|
||
<div style="flex:1;min-width:100px">
|
||
<label style="font-size:12px;color:var(--text-muted);display:block;margin-bottom:4px">向量TopK</label>
|
||
<input type="number" class="form-control" id="testVectorTopK" value="60" min="10" max="100">
|
||
</div>
|
||
<div style="flex:1;min-width:100px">
|
||
<label style="font-size:12px;color:var(--text-muted);display:block;margin-bottom:4px">关键词TopK</label>
|
||
<input type="number" class="form-control" id="testKeywordTopK" value="60" min="10" max="100">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 快捷问题 -->
|
||
<div class="quick-questions">
|
||
${quickQuestions.map(q => `
|
||
<span class="quick-question-tag" onclick="document.getElementById('testQuery').value='${q}';window.__runHitTest()">${q}</span>
|
||
`).join('')}
|
||
</div>
|
||
|
||
<!-- 查询输入 -->
|
||
<div class="hit-test-query">
|
||
<input type="text" class="form-control" id="testQuery" placeholder="输入问题进行测试...">
|
||
<button class="btn btn-primary" onclick="window.__runHitTest()">
|
||
<i data-lucide="play" style="width:14px;height:14px"></i>
|
||
测试
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 搜索结果 -->
|
||
<div style="font-size:13px;font-weight:500;margin-bottom:12px">搜索结果</div>
|
||
<div class="hit-test-results" id="testResults">
|
||
<div style="padding:40px;text-align:center;color:var(--text-muted)">
|
||
<i data-lucide="search" style="width:32px;height:32px;margin-bottom:8px"></i>
|
||
<p>输入问题并点击测试查看结果</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 历史记录 -->
|
||
<div style="margin-top:16px">
|
||
<div class="history-record" id="historyRecord">
|
||
<div class="history-record-header" onclick="this.closest('.history-record').classList.toggle('open')">
|
||
<span>
|
||
<i data-lucide="clock" style="width:14px;height:14px;margin-right:6px;vertical-align:middle"></i>
|
||
历史召回记录
|
||
</span>
|
||
<i data-lucide="chevron-down" style="width:14px;height:14px"></i>
|
||
</div>
|
||
<div class="history-record-body">
|
||
${testHistory.map(h => `
|
||
<div class="history-record-item" onclick="document.getElementById('testQuery').value='${h.query}';window.__runHitTest()">
|
||
<div class="history-record-time">${h.time}</div>
|
||
<div class="history-record-query">问: ${h.query}</div>
|
||
<div class="history-record-result">召回 ${h.recallCount} 个切片,相关度 ${(h.avgScore * 100).toFixed(0)}%</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`,
|
||
confirmText: '',
|
||
cancelText: '关闭'
|
||
});
|
||
lucide.createIcons();
|
||
};
|
||
|
||
window.__runHitTest = function() {
|
||
const query = document.getElementById('testQuery')?.value.trim();
|
||
if (!query) {
|
||
Toast.error('请输入测试问题');
|
||
return;
|
||
}
|
||
|
||
// TODO: 调用 API GET /api/v1/knowledge-bases/:id/hit-test
|
||
const resultsHtml = mockSlices.map(slice => `
|
||
<div class="hit-test-result-item">
|
||
<div class="hit-test-result-header">
|
||
<span class="hit-test-result-source">
|
||
<i data-lucide="file-text" style="width:14px;height:14px"></i>
|
||
${slice.source}
|
||
</span>
|
||
<span style="font-size:12px;color:var(--text-muted)">相关度: ${(slice.score * 100).toFixed(0)}%</span>
|
||
</div>
|
||
<div class="hit-test-result-meta">
|
||
<span><i data-lucide="type" style="width:12px;height:12px"></i> ${slice.chars} 字符</span>
|
||
<span><i data-lucide="hash" style="width:12px;height:12px"></i> 切片 ${slice.sliceNo}</span>
|
||
</div>
|
||
<div class="hit-test-result-content">${slice.content}</div>
|
||
<div style="margin-top:8px;display:flex;gap:4px">
|
||
<button class="btn btn-ghost btn-sm" onclick="this.closest('.hit-test-result-item').querySelector('.hit-test-result-content').classList.toggle('expanded')">
|
||
展开 <i data-lucide="chevron-down" style="width:12px;height:12px"></i>
|
||
</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="window.__editSlice('${slice.id}')">
|
||
<i data-lucide="edit" style="width:12px;height:12px"></i> 编辑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
document.getElementById('testResults').innerHTML = resultsHtml;
|
||
lucide.createIcons();
|
||
Toast.success('测试完成');
|
||
};
|
||
|
||
// 知识库操作
|
||
window.__editKb = function() {
|
||
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="detailKbName" maxlength="64" value="${escapeHtml(kbData.name)}" placeholder="请输入知识库名称">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">描述</label>
|
||
<textarea class="form-control" id="detailKbDesc" rows="3" maxlength="500">${escapeHtml(kbData.description || '')}</textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">标签</label>
|
||
<input class="form-control" id="detailKbTags" value="${escapeHtml(kbData.tags.join(', '))}" placeholder="多个标签用逗号分隔">
|
||
</div>
|
||
`,
|
||
confirmText: '保存',
|
||
onConfirm: () => {
|
||
const name = document.getElementById('detailKbName')?.value.trim();
|
||
if (!name) {
|
||
Toast.warning('请输入知识库名称');
|
||
return false;
|
||
}
|
||
kbData.name = name;
|
||
kbData.description = document.getElementById('detailKbDesc')?.value.trim() || '';
|
||
kbData.tags = (document.getElementById('detailKbTags')?.value || '')
|
||
.split(/[,,]/)
|
||
.map(tag => tag.trim())
|
||
.filter(Boolean);
|
||
kbData.updatedAt = getNowText();
|
||
const catalog = Array.isArray(window.__knowledgeBaseCatalog) ? window.__knowledgeBaseCatalog : [];
|
||
const target = catalog.find(item => item.id === kbData.id);
|
||
if (target) Object.assign(target, {
|
||
name: kbData.name,
|
||
description: kbData.description,
|
||
tags: [...kbData.tags],
|
||
updatedAt: kbData.updatedAt,
|
||
});
|
||
renderPage();
|
||
Toast.success('知识库信息已更新');
|
||
}
|
||
});
|
||
};
|
||
|
||
window.__rebuildAllIndex = function() {
|
||
createModal({
|
||
title: '重建全部索引',
|
||
content: `
|
||
<p>确定要重建知识库「${kbData.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
|
||
kbData.status = 'processing';
|
||
kbData.updatedAt = getNowText();
|
||
const catalog = Array.isArray(window.__knowledgeBaseCatalog) ? window.__knowledgeBaseCatalog : [];
|
||
const target = catalog.find(item => item.id === kbData.id);
|
||
if (target) {
|
||
target.status = kbData.status;
|
||
target.updatedAt = kbData.updatedAt;
|
||
}
|
||
renderPage();
|
||
Toast.success('索引重建任务已启动');
|
||
}
|
||
});
|
||
};
|
||
|
||
window.__reparseAll = function() {
|
||
createModal({
|
||
title: '重新解析全部',
|
||
content: `
|
||
<p>确定要重新解析知识库「${kbData.name}」的全部文件吗?</p>
|
||
<p style="color:var(--warning);font-size:13px;margin-top:8px">此操作将重新解析所有文件,请耐心等待。</p>
|
||
`,
|
||
confirmText: '重新解析',
|
||
onConfirm: () => {
|
||
kbFiles = kbFiles.map(file => ({
|
||
...file,
|
||
status: 'processing',
|
||
indexedAt: '-',
|
||
parseError: null,
|
||
}));
|
||
kbData.status = 'processing';
|
||
kbData.updatedAt = getNowText();
|
||
const catalog = Array.isArray(window.__knowledgeBaseCatalog) ? window.__knowledgeBaseCatalog : [];
|
||
const target = catalog.find(item => item.id === kbData.id);
|
||
if (target) {
|
||
target.status = kbData.status;
|
||
target.updatedAt = kbData.updatedAt;
|
||
}
|
||
selectedFiles.clear();
|
||
renderPage();
|
||
Toast.success('重新解析任务已启动');
|
||
}
|
||
});
|
||
};
|
||
|
||
window.__exportKb = function() {
|
||
createModal({
|
||
title: '导出知识库',
|
||
content: `
|
||
<div class="form-group">
|
||
<label class="form-label">导出格式</label>
|
||
<select class="form-control" id="detailExportFormat">
|
||
<option value="json">JSON 完整数据</option>
|
||
<option value="csv">CSV 文件列表</option>
|
||
</select>
|
||
</div>
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" id="detailExportIncludeFiles" checked>
|
||
<span>包含文件列表和切片摘要</span>
|
||
</label>
|
||
<div class="form-hint">浏览器会下载到默认下载目录;后端接入后替换为 GET /api/v1/knowledge-bases/:id/export。</div>
|
||
`,
|
||
confirmText: '确认导出',
|
||
onConfirm: () => {
|
||
const format = document.getElementById('detailExportFormat')?.value || 'json';
|
||
const includeFiles = document.getElementById('detailExportIncludeFiles')?.checked !== false;
|
||
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||
if (format === 'csv') {
|
||
const rows = [
|
||
'id,name,format,size,status,category,slices,chars,indexedAt',
|
||
...kbFiles.map(file => [
|
||
file.id,
|
||
file.name,
|
||
file.format,
|
||
file.size,
|
||
file.status,
|
||
file.category,
|
||
file.slices,
|
||
file.chars,
|
||
file.indexedAt,
|
||
].map(value => `"${String(value ?? '').replace(/"/g, '""')}"`).join(',')),
|
||
].join('\n');
|
||
downloadTextFile(`${kbData.id}-files-${stamp}.csv`, rows, 'text/csv;charset=utf-8');
|
||
} else {
|
||
downloadTextFile(`${kbData.id}-${stamp}.json`, JSON.stringify({
|
||
knowledgeBase: kbData,
|
||
files: includeFiles ? kbFiles : undefined,
|
||
slices: includeFiles ? mockSlices : undefined,
|
||
}, null, 2));
|
||
}
|
||
Toast.success('知识库已导出');
|
||
}
|
||
});
|
||
};
|
||
|
||
window.__deleteKb = function() {
|
||
createModal({
|
||
title: '删除知识库',
|
||
content: `
|
||
<p>确定要删除知识库「${kbData.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
|
||
if (Array.isArray(window.__knowledgeBaseCatalog)) {
|
||
const index = window.__knowledgeBaseCatalog.findIndex(item => item.id === kbData.id);
|
||
if (index !== -1) window.__knowledgeBaseCatalog.splice(index, 1);
|
||
}
|
||
Toast.success('知识库已删除');
|
||
setTimeout(() => navigateTo('knowledge-base'), 1000);
|
||
}
|
||
});
|
||
lucide.createIcons();
|
||
};
|
||
|
||
// ==================== 初始化 ====================
|
||
|
||
setTimeout(() => {
|
||
isLoading = false;
|
||
renderPage();
|
||
}, 300);
|
||
});
|