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

1599 lines
65 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 - 创建知识库向导页面
* 包含3步流程基础信息 -> 数据选择 -> 索引设置
*
* API预留:
* - POST /api/v1/knowledge-bases - 创建知识库
* - POST /api/v1/knowledge-bases/validate-name - 验证名称唯一性
* - POST /api/v1/files/upload - 文件上传
* - GET /api/v1/files/upload-status/:id - 获取上传状态
*/
registerPage('kb-create', function(container) {
// ==================== 状态管理 ====================
let currentStep = 1;
const totalSteps = 3;
let hasUnsavedChanges = false;
const DRAFT_KEY = 'kb_create_draft';
// ==================== API预留 - 后端接口定义 ====================
/**
* 创建知识库
* POST /api/v1/knowledge-bases
* Body: { name, description, type, sourceType, sourceConfig, indexConfig }
* Response: { id: string, name: string, status: string }
*/
const API_CREATE = '/api/v1/knowledge-bases';
/**
* 验证名称唯一性
* POST /api/v1/knowledge-bases/validate-name
* Body: { name: string }
* Response: { valid: boolean, message?: string }
*/
const API_VALIDATE_NAME = '/api/v1/knowledge-bases/validate-name';
/**
* 文件上传
* POST /api/v1/files/upload
* FormData: { file, category, knowledgeBaseId? }
* Response: { fileId: string, fileName: string, status: string }
*/
const API_UPLOAD = '/api/v1/files/upload';
/**
* 获取上传/解析状态
* GET /api/v1/files/status/:fileId
* Response: { status: 'uploading'|'processing'|'ready'|'error', progress?: number, error?: string }
*/
const API_FILE_STATUS = '/api/v1/files/status/:fileId';
/**
* 获取类目列表
* GET /api/v1/categories
* Response: { list: [{ id, name, fileCount }] }
*/
const API_GET_CATEGORIES = '/api/v1/categories';
// ==================== 表单数据 ====================
const formData = {
// 步骤1: 基础信息
name: '',
description: '',
type: '',
// 步骤2: 数据选择
sourceType: 'upload', // upload | category | files
category: '',
uploadedFiles: [],
autoSync: false,
parseMethod: 'inherit', // inherit | default | custom
// 自定义解析配置
docParseMethods: ['electronic'],
imageParseMethods: ['smart'],
videoParseMethods: ['media'],
enableVideoParse: false,
// 步骤3: 索引设置
sliceMethod: 'smart', // smart | length | page | title | regex | symbol
maxChunkLength: 2000,
overlapLength: 200,
titleLevel: 3,
regexPattern: '',
symbolChars: ',',
enableMetadataExtract: true,
enableExcelHeader: false,
enableMultiTurn: false,
enableOCR: false,
enableAutoKeywords: false,
enableAutoTags: false,
vectorModel: 'text-embedding-v2',
rerankModel: 'bge-reranker-v2.5',
rerankMode: 'qa', // qa | similarity | custom
vectorTopK: 60,
keywordTopK: 60,
similarityThreshold: 0.5,
maxRecallCount: 10,
vectorStorage: 'platform'
};
// ==================== 知识库类型定义 ====================
const kbTypes = [
{
id: 'doc_search',
icon: 'file-text',
title: '文档搜索',
desc: '构建文档、文件、图片、Excel混合型索引知识库',
tooltip: '构建文档、文件、图片、Excel混合型索引知识库构建常规混合搜索知识库。支持PDF、Word、Excel、图片等多种格式文档。'
},
{
id: 'data_query',
icon: 'table',
title: '数据查询',
desc: '基于NL2SQL的表格数据查询',
tooltip: '构建仅以数据表结构查询的数据索引体系基于NL2SQL思路实现表头、列头等结构化数据查询能力。适合结构化数据问答场景。'
},
{
id: 'image_qa',
icon: 'image',
title: '图片问答',
desc: '基于多模态Embedding的图片检索',
tooltip: '构建仅以图片索引为主的知识库基于多模态Embedding能力实现图片名称与图片内容搜索。适合图片素材检索场景。'
},
{
id: 'video_search',
icon: 'video',
title: '音视频搜索',
desc: '音视频内容智能理解',
tooltip: '构建音视频搜索知识库基于高精度解析与多模态Embedding检索技术对音视频内容进行智能理解与提炼。支持字幕提取、内容摘要等。'
}
];
// ==================== 切片方式 ====================
const sliceMethods = [
{ id: 'smart', label: '智能切分', desc: '根据语义自动识别段落边界' },
{ id: 'length', label: '按长度切分', desc: '固定长度切分,支持重叠' },
{ id: 'page', label: '按页切分', desc: '按页分割文档' },
{ id: 'title', label: '按标题切分', desc: '按标题层级结构切分' },
{ id: 'regex', label: '按正则切分', desc: '自定义正则表达式切分' },
{ id: 'symbol', label: '按符号切分', desc: '按标点符号切分' }
];
// ==================== 符号选项 ====================
const symbolOptions = [
{ value: ',', label: '中文逗号 ' },
{ value: '。', label: '中文句号 。' },
{ value: '', label: '中文逗号(英文)' },
{ value: '.', label: '英文句号 .' },
{ value: '、', label: '顿号 、' },
{ value: '', label: '中文问号 ' },
{ value: '', label: '中文感叹号 ' },
{ value: '?', label: '英文问号 ?' },
{ value: '!', label: '英文感叹号 !' },
{ value: '\n', label: '换行符' },
{ value: '\n\n', label: '双换行符' }
];
// ==================== 类目数据 ====================
const categories = [
{ id: 'cat-001', name: '默认类目', fileCount: 128 },
{ id: 'cat-002', name: '教学资料', fileCount: 56 },
{ id: 'cat-003', name: '产品文档', fileCount: 34 },
{ id: 'cat-004', name: '用户指南', fileCount: 89 },
];
// ==================== 工具函数 ====================
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function getFormatsByType() {
switch (formData.type) {
case 'image_qa':
return '.png,.jpg,.jpeg,.bmp,.gif';
case 'video_search':
return '.mp4,.avi,.mov,.mkv,.wmv,.mp3,.wav,.m4a';
case 'data_query':
return '.xlsx,.xls,.csv';
default:
return '.pdf,.doc,.docx,.txt,.md,.ppt,.pptx,.xlsx,.xls,.png,.jpg,.jpeg,.gif';
}
}
function getFileIcon(filename) {
const ext = filename?.split('.').pop()?.toLowerCase() || '';
const icons = {
pdf: '📄', doc: '📄', docx: '📄', txt: '📝', md: '📋',
xlsx: '📊', xls: '📊', csv: '📊',
png: '🖼', jpg: '🖼', jpeg: '🖼', gif: '🖼', bmp: '🖼',
mp4: '🎬', avi: '🎬', mov: '🎬', mkv: '🎬', wmv: '🎬',
mp3: '🎵', wav: '🎵', m4a: '🎵'
};
return icons[ext] || '📁';
}
function getFileStatusText(status) {
const map = {
pending: '待上传',
uploading: '上传中',
processing: '解析中',
ready: '解析完成',
error: '解析失败'
};
return map[status] || status;
}
// ==================== 草稿管理 ====================
function loadDraft() {
try {
const draft = localStorage.getItem(DRAFT_KEY);
if (draft) {
const saved = JSON.parse(draft);
Object.assign(formData, saved.formData || {});
currentStep = saved.currentStep || 1;
return true;
}
} catch (e) {
console.error('Failed to load draft:', e);
}
return false;
}
function saveDraft() {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify({
formData,
currentStep,
savedAt: new Date().toISOString()
}));
Toast.success('草稿已保存');
} catch (e) {
Toast.warning('草稿保存失败');
}
}
function clearDraft() {
localStorage.removeItem(DRAFT_KEY);
}
// ==================== 验证函数 ====================
function validateStep1() {
const errors = [];
if (!formData.name.trim()) {
errors.push('请输入知识库名称');
} else if (formData.name.length < 2 || formData.name.length > 64) {
errors.push('知识库名称长度需在2-64字符之间');
}
if (!formData.type) {
errors.push('请选择知识库类型');
}
return errors;
}
function validateStep2() {
const errors = [];
if (formData.sourceType === 'upload' && formData.uploadedFiles.length === 0) {
errors.push('请上传至少一个文件');
}
if (formData.sourceType === 'upload') {
const failedFiles = formData.uploadedFiles.filter(f => f.status === 'error');
if (failedFiles.length > 0) {
errors.push(`${failedFiles.length} 个文件上传失败,请处理后重试`);
}
}
return errors;
}
function validateStep3() {
return []; // 步骤3一般不需要强制验证
}
// ==================== 渲染函数 ====================
/**
* 渲染向导步骤
*/
function renderWizardSteps() {
const steps = ['基础信息', '数据选择', '索引设置'];
return `
<div class="wizard-steps">
${[1, 2, 3].map((step, idx) => `
<div class="wizard-step ${step < currentStep ? 'completed' : ''} ${step === currentStep ? 'active' : ''}">
<div class="wizard-step-num">
${step < currentStep ? '<i data-lucide="check" style="width:14px;height:14px"></i>' : step}
</div>
<span class="wizard-step-label">${steps[step - 1]}</span>
</div>
${idx < 2 ? `<div class="wizard-step-line ${step < currentStep ? 'completed' : ''}"></div>` : ''}
`).join('')}
</div>
`;
}
/**
* 渲染步骤1基础信息
*/
function renderStep1() {
return `
<div class="wizard-content">
<h3 style="font-size:16px;font-weight:600;margin-bottom:24px">
基础知识信息
<span class="help-tip" onclick="window.__showTooltip('step1Tip')">?</span>
</h3>
<div id="step1Tip" class="tooltip-box" style="display:none;margin-bottom:20px">
<div style="padding:16px;font-size:13px;color:#fff;line-height:1.6">
<p><strong>知识库名称</strong>:用于标识知识库,建议简洁明了</p>
<p style="margin-top:8px"><strong>知识库类型</strong>:根据您的数据类型选择合适的类型</p>
</div>
</div>
<!-- 知识库名称 -->
<div class="form-group" style="margin-bottom:20px">
<label class="form-label">
知识库名称 <span style="color:var(--danger)">*</span>
</label>
<input type="text" class="form-control" id="kbName"
placeholder="请输入知识库名称2-64个字符"
maxlength="64"
value="${escapeHtml(formData.name)}"
oninput="window.__updateCharCount(this,64);hasUnsavedChanges=true">
<div class="char-counter" id="kbNameCounter">${formData.name.length}/64</div>
</div>
<!-- 知识库描述 -->
<div class="form-group" style="margin-bottom:24px">
<label class="form-label">
知识库描述
<span class="help-tip" onclick="window.__showTooltip('descTip')">?</span>
</label>
<div id="descTip" class="tooltip-box" style="display:none;margin-bottom:8px">
<div style="padding:12px;font-size:13px;color:#fff">
描述可以帮助您和其他用户理解知识库的内容和用途。可选填写。
</div>
</div>
<textarea class="form-control" id="kbDesc" placeholder="请输入知识库描述(可选)" rows="3" maxlength="500"
oninput="window.__updateCharCount(this,500);hasUnsavedChanges=true">${escapeHtml(formData.description)}</textarea>
<div class="char-counter" id="kbDescCounter">${formData.description.length}/500</div>
</div>
<!-- 知识库类型 -->
<div class="form-group">
<label class="form-label">
知识库类型 <span style="color:var(--danger)">*</span>
</label>
<div class="kb-type-grid">
${kbTypes.map(type => `
<div class="kb-type-card ${formData.type === type.id ? 'selected' : ''}"
onclick="window.__selectKbType('${type.id}')" data-type="${type.id}">
<div class="check-icon"><i data-lucide="check" style="width:12px;height:12px"></i></div>
<div class="kb-type-card-icon"><i data-lucide="${type.icon}" style="width:32px;height:32px"></i></div>
<div class="kb-type-card-title">${type.title}</div>
<div class="kb-type-card-desc">${type.desc}</div>
<div class="kb-type-tooltip" style="display:none;position:absolute;bottom:-60px;left:50%;transform:translateX(-50%);background:#1a1a1a;color:#fff;padding:8px 12px;border-radius:6px;font-size:12px;white-space:nowrap;z-index:10;box-shadow:0 4px 12px rgba(0,0,0,0.2)">
${type.tooltip}
<div style="position:absolute;top:-6px;left:50%;transform:translateX(-50%);border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid #1a1a1a"></div>
</div>
</div>
`).join('')}
</div>
</div>
<div class="form-help-link" onclick="window.__showKbTypeHelp()">
<i data-lucide="help-circle" style="width:14px;height:14px"></i>
了解各类型区别
</div>
</div>
`;
}
/**
* 渲染步骤2数据选择
*/
function renderStep2() {
const formats = getFormatsByType();
const maxFiles = 50;
const maxSize = formData.type === 'image_qa' || formData.type === 'video_search' ? '20MB' : '50MB';
return `
<div class="wizard-content">
<h3 style="font-size:16px;font-weight:600;margin-bottom:24px">选择数据源</h3>
<!-- 数据连接器说明 -->
<div class="api-note" style="margin-bottom:20px">
<i data-lucide="info" style="width:16px;height:16px;flex-shrink:0"></i>
<div>
<strong>默认文件连接器</strong>
<p style="margin-top:4px;font-size:12px">从连接器内选择已解析文档构建知识索引,也支持直接上传文件解析并构建索引,上传文件将自动存储至连接器。</p>
</div>
</div>
<!-- 数据来源类型 -->
<div class="data-source-tabs">
<div class="data-source-tab ${formData.sourceType === 'upload' ? 'active' : ''}"
onclick="window.__selectSourceType('upload')">
<i data-lucide="upload" style="width:14px;height:14px;margin-right:6px;vertical-align:middle"></i>
文件上传
</div>
<div class="data-source-tab ${formData.sourceType === 'category' ? 'active' : ''}"
onclick="window.__selectSourceType('category')">
<i data-lucide="folder" style="width:14px;height:14px;margin-right:6px;vertical-align:middle"></i>
选择类目
</div>
<div class="data-source-tab ${formData.sourceType === 'files' ? 'active' : ''}"
onclick="window.__selectSourceType('files')">
<i data-lucide="file" style="width:14px;height:14px;margin-right:6px;vertical-align:middle"></i>
选择文件
</div>
</div>
<div id="sourceContent">
${renderSourceContent(formats, maxFiles, maxSize)}
</div>
<!-- 自动同步开关 -->
${formData.sourceType !== 'upload' ? `
<div class="sync-switch" style="margin-top:20px">
<div class="sync-switch-label">
<div>
开启自动同步知识索引
<span class="help-tip" onclick="window.__showTooltip('autoSyncTip')">?</span>
</div>
<div id="autoSyncTip" class="tooltip-box" style="display:none;margin-top:8px">
<div style="padding:12px;font-size:13px;color:#fff">
开启自动同步后,当所选类目下的文件发生新增、修改、删除时,系统会自动更新知识库索引,无需手动操作。
</div>
</div>
<div class="sync-switch-desc">
开启后将自动同步类目下所有文件,在类目文档发生变化后,自动重建及更新索引。
</div>
</div>
<label class="switch">
<input type="checkbox" id="autoSync" ${formData.autoSync ? 'checked' : ''} onchange="window.__toggleAutoSync(this.checked)">
<span class="switch-slider"></span>
</label>
</div>
` : ''}
</div>
`;
}
/**
* 渲染数据源内容
*/
function renderSourceContent(formats, maxFiles, maxSize) {
switch (formData.sourceType) {
case 'upload':
return renderUploadContent(formats, maxFiles, maxSize);
case 'category':
return renderCategoryContent();
case 'files':
return renderFilesContent();
default:
return renderUploadContent(formats, maxFiles, maxSize);
}
}
/**
* 渲染文件上传内容
*/
function renderUploadContent(formats, maxFiles, maxSize) {
return `
<!-- 类目选择 -->
<div class="category-selector">
<span style="font-size:13px;font-weight:500;flex-shrink:0">配置类目:</span>
<select class="form-control" id="uploadCategory" style="flex:1;max-width:300px"
onchange="window.__updateCategory(this.value)">
<option value="">默认类目</option>
${categories.map(c => `<option value="${c.id}" ${formData.category === c.id ? 'selected' : ''}>${c.name} (${c.fileCount}个文件)</option>`).join('')}
</select>
<button class="btn btn-outline btn-sm" onclick="window.__showCategoryDrawer()">
<i data-lucide="plus" style="width:14px;height:14px"></i> 新建类目
</button>
</div>
<!-- 上传区域 -->
<div class="upload-zone" id="uploadZone"
ondragover="window.__handleDragOver(event)" ondragleave="window.__handleDragLeave(event)" ondrop="window.__handleDrop(event)">
<div class="upload-zone-icon">📁</div>
<div class="upload-zone-text">点击或拖拽文件到此处上传</div>
<div style="display:flex;gap:12px;justify-content:center;align-items:center">
<button class="btn btn-primary btn-sm" onclick="document.getElementById('fileInput').click()">
<i data-lucide="folder-open" style="width:14px;height:14px"></i> 选择文件
</button>
<button class="btn btn-outline btn-sm" onclick="document.getElementById('folderInput').click()">
<i data-lucide="folder" style="width:14px;height:14px"></i> 选择文件夹
</button>
</div>
<input type="file" id="fileInput" multiple style="display:none" accept="${formats}"
onchange="window.__handleFileSelect(this.files)">
<input type="file" id="folderInput" webkitdirectory multiple style="display:none"
onchange="window.__handleFileSelect(this.files)">
<div class="upload-zone-hint">
支持格式: ${formats}<br>
单文件最大: ${maxSize} | 最多: ${maxFiles}个文件
</div>
</div>
<!-- 文件列表 -->
${formData.uploadedFiles.length > 0 ? `
<div class="file-list">
<div class="file-list-header">
<span style="flex:1">文件名</span>
<span style="width:80px">大小</span>
<span style="width:120px">状态</span>
<span style="width:80px">操作</span>
</div>
${formData.uploadedFiles.map((file, idx) => `
<div class="file-list-item">
<div class="file-icon">${getFileIcon(file.name)}</div>
<div class="file-info">
<div class="file-name">${escapeHtml(file.name)}</div>
<div class="file-meta">${file.size}</div>
</div>
<span class="file-status ${file.status}">
${file.status === 'uploading' ? `<span class="spinner"></span>` : ''}
${file.status === 'processing' ? `<span class="spinner"></span>` : ''}
${getFileStatusText(file.status)}
</span>
<div class="file-actions">
${file.status === 'error' ? `
<button class="btn btn-ghost btn-sm" data-tooltip="重试上传" onclick="window.__retryUpload(${idx})">
<i data-lucide="refresh-cw" style="width:14px;height:14px"></i>
</button>
` : ''}
<button class="btn btn-ghost btn-sm" data-tooltip="删除文件" onclick="window.__removeFile(${idx})">
<i data-lucide="x" style="width:14px;height:14px"></i>
</button>
</div>
</div>
`).join('')}
</div>
<div style="margin-top:12px;font-size:12px;color:var(--text-muted);text-align:right">
已选择 ${formData.uploadedFiles.length}/${maxFiles} 个文件
<button class="btn btn-ghost btn-sm" style="margin-left:12px" onclick="window.__clearAllFiles()">清空</button>
</div>
` : ''}
<!-- 解析方式 -->
<div style="margin-top:24px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="font-size:13px;font-weight:500">解析方式</span>
<span class="help-tip" onclick="window.__showTooltip('parseMethodTip')">?</span>
</div>
<div id="parseMethodTip" class="tooltip-box" style="display:none;margin-bottom:8px">
<div style="padding:12px;font-size:13px;color:#fff">
<p><strong>继承目录配置</strong>:使用类目中预设的解析配置</p>
<p style="margin-top:8px"><strong>默认解析</strong>:使用系统默认的文档解析方式</p>
<p style="margin-top:8px"><strong>自定义解析</strong>:根据文档类型选择不同的解析器</p>
</div>
</div>
<div class="parse-method-selector">
<div class="parse-method-option ${formData.parseMethod === 'inherit' ? 'selected' : ''}" onclick="window.__selectParseMethod('inherit')">
<i data-lucide="settings" style="width:14px;height:14px"></i>
继承目录配置
</div>
<div class="parse-method-option ${formData.parseMethod === 'default' ? 'selected' : ''}" onclick="window.__selectParseMethod('default')">
<i data-lucide="check-circle" style="width:14px;height:14px"></i>
默认解析
</div>
<div class="parse-method-option ${formData.parseMethod === 'custom' ? 'selected' : ''}" onclick="window.__selectParseMethod('custom')">
<i data-lucide="edit" style="width:14px;height:14px"></i>
自定义解析
</div>
</div>
${formData.parseMethod === 'custom' ? `
<div id="parseMethodDetail" style="margin-top:16px;padding:16px;background:var(--bg-base);border-radius:var(--radius)">
${renderParseMethodDetail()}
</div>
` : ''}
</div>
`;
}
/**
* 渲染自定义解析方式
*/
function renderParseMethodDetail() {
const type = formData.type;
let html = '<div style="font-size:13px"><strong>自定义解析方式:</strong></div>';
if (['doc_search', 'data_query', ''].includes(type)) {
html += `
<div style="margin-top:12px">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">文档类型</div>
<div class="parse-method-selector">
<div class="parse-method-option ${formData.docParseMethods.includes('electronic') ? 'selected' : ''}" onclick="window.__toggleParseMethod('doc','electronic')">
<i data-lucide="${formData.docParseMethods.includes('electronic') ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
电子文档解析
</div>
<div class="parse-method-option ${formData.docParseMethods.includes('md') ? 'selected' : ''}" onclick="window.__toggleParseMethod('doc','md')">
<i data-lucide="${formData.docParseMethods.includes('md') ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
Markdown解析
</div>
<div class="parse-method-option ${formData.docParseMethods.includes('html') ? 'selected' : ''}" onclick="window.__toggleParseMethod('doc','html')">
<i data-lucide="${formData.docParseMethods.includes('html') ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
HTML解析
</div>
<div class="parse-method-option ${formData.docParseMethods.includes('ppt') ? 'selected' : ''}" onclick="window.__toggleParseMethod('doc','ppt')">
<i data-lucide="${formData.docParseMethods.includes('ppt') ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
PPT文档智能解析
</div>
<div class="parse-method-option ${formData.docParseMethods.includes('llm') ? 'selected' : ''}" onclick="window.__toggleParseMethod('doc','llm')">
<i data-lucide="${formData.docParseMethods.includes('llm') ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
大模型文档解析
</div>
</div>
</div>
`;
}
if (type === 'image_qa' || !type) {
html += `
<div style="margin-top:16px">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">图片类型</div>
<div class="parse-method-selector">
<div class="parse-method-option ${formData.imageParseMethods.includes('smart') ? 'selected' : ''}" onclick="window.__toggleParseMethod('image','smart')">
<i data-lucide="${formData.imageParseMethods.includes('smart') ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
文档智能解析
</div>
<div class="parse-method-option ${formData.imageParseMethods.includes('llm') ? 'selected' : ''}" onclick="window.__toggleParseMethod('image','llm')">
<i data-lucide="${formData.imageParseMethods.includes('llm') ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
大模型文档解析
</div>
<div class="parse-method-option ${formData.imageParseMethods.includes('qwen_vl') ? 'selected' : ''}" onclick="window.__toggleParseMethod('image','qwen_vl')">
<i data-lucide="${formData.imageParseMethods.includes('qwen_vl') ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
Qwen VL解析
</div>
</div>
</div>
`;
}
if (type === 'video_search' || !type) {
html += `
<div style="margin-top:16px">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">音视频类型</div>
<div class="parse-method-selector">
<div class="parse-method-option ${formData.videoParseMethods.includes('media') ? 'selected' : ''}" onclick="window.__toggleParseMethod('video','media')">
<i data-lucide="${formData.videoParseMethods.includes('media') ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
音视频解析
</div>
<label style="display:flex;align-items:center;gap:8px;margin-top:8px;font-size:13px;padding:8px 0;cursor:pointer">
<input type="checkbox" id="enableVideoParse" ${formData.enableVideoParse ? 'checked' : ''} onchange="formData.enableVideoParse=this.checked">
开启视频解析
</label>
</div>
</div>
`;
}
return html;
}
/**
* 渲染类目选择内容
*/
function renderCategoryContent() {
return `
<div class="category-selector">
<span style="font-size:13px;font-weight:500;flex-shrink:0">选择类目:</span>
<select class="form-control" id="selectCategory" style="flex:1;max-width:300px"
onchange="window.__loadCategoryFiles(this.value)">
${categories.map(c => `<option value="${c.id}">${c.name} (${c.fileCount}个文件)</option>`).join('')}
</select>
<button class="btn btn-outline btn-sm" onclick="window.__showCategoryDrawer()">
<i data-lucide="plus" style="width:14px;height:14px"></i> 新建类目
</button>
<button class="btn btn-ghost btn-sm" onclick="window.__showCategoryDrawer()">
<i data-lucide="settings" style="width:14px;height:14px"></i> 类目管理
</button>
</div>
<div class="api-note" style="margin-top:16px">
<i data-lucide="folder" style="width:16px;height:16px;flex-shrink:0"></i>
<div>
<p>选择类目后,该类目下所有文件将被导入并构建索引。</p>
<p style="margin-top:4px;font-size:12px">当前类目包含 <strong>128</strong> 个文件,预计解析时间约 5-10 分钟。</p>
</div>
</div>
`;
}
/**
* 渲染文件选择内容
*/
function renderFilesContent() {
const appFiles = [
{ name: '产品说明书.pdf', size: '3.2MB', format: 'PDF' },
{ name: '用户指南.docx', size: '1.5MB', format: 'DOCX' },
{ name: '技术规格.xlsx', size: '856KB', format: 'XLSX' },
{ name: '培训视频.mp4', size: '128MB', format: 'MP4' },
{ name: '常见问题.md', size: '245KB', format: 'MD' },
];
return `
<div class="category-selector">
<span style="font-size:13px;font-weight:500;flex-shrink:0">应用数据类目:</span>
<select class="form-control" id="appCategory" style="flex:1;max-width:300px">
${categories.map(c => `<option value="${c.id}">${c.name}</option>`).join('')}
</select>
</div>
<div class="file-compare">
<div class="file-compare-panel">
<div class="file-compare-header">
<i data-lucide="database" style="width:14px;height:14px"></i>
数据中心文件
</div>
<div class="file-compare-body">
${appFiles.map((f, idx) => `
<div class="file-compare-item" onclick="window.__toggleFileSelect(this, '${f.name}')">
<input type="checkbox" style="margin-right:8px" id="file_${idx}">
<div class="file-icon">${getFileIcon(f.name)}</div>
<div>
<div style="font-size:13px">${f.name}</div>
<div style="font-size:11px;color:var(--text-muted)">${f.format} · ${f.size}</div>
</div>
</div>
`).join('')}
</div>
</div>
<div class="file-compare-panel">
<div class="file-compare-header">
<i data-lucide="check-square" style="width:14px;height:14px;color:var(--primary)"></i>
已选择 <span id="selectedCount">0</span>/50
</div>
<div class="file-compare-body" id="selectedAppFiles">
<div class="file-compare-empty">
<i data-lucide="inbox" style="width:32px;height:32px;color:var(--text-muted);margin-bottom:8px"></i>
<p>从左侧选择文件</p>
<p style="font-size:11px;margin-top:4px">最多支持50个文件</p>
</div>
</div>
</div>
</div>
`;
}
/**
* 渲染步骤3索引设置
*/
function renderStep3() {
return `
<div class="wizard-content">
<h3 style="font-size:16px;font-weight:600;margin-bottom:24px">
索引设置
<span class="help-tip" onclick="window.__showTooltip('step3Tip')">?</span>
</h3>
<div id="step3Tip" class="tooltip-box" style="display:none;margin-bottom:20px">
<div style="padding:16px;font-size:13px;color:#fff;line-height:1.6">
<p><strong>切片方式</strong>:决定如何将文档分割成小块进行索引。</p>
<p style="margin-top:8px"><strong>检索配置</strong>:影响检索效果和性能,建议使用推荐配置。</p>
</div>
</div>
<!-- 切片方式 -->
<div class="form-group" style="margin-bottom:20px">
<label class="form-label">
切片方式 <span style="color:var(--danger)">*</span>
</label>
<select class="form-control" id="sliceMethod" onchange="window.__updateSliceConfig();hasUnsavedChanges=true">
${sliceMethods.map(m => `
<option value="${m.id}" ${formData.sliceMethod === m.id ? 'selected' : ''}>${m.label} - ${m.desc}</option>
`).join('')}
</select>
</div>
<!-- 切片配置 -->
<div class="slice-config-panel" id="sliceConfigPanel">
${renderSliceConfig()}
</div>
<!-- 高级配置折叠面板 -->
<div class="advanced-config" style="margin-top:24px">
<div class="advanced-config-header" onclick="this.closest('.advanced-config').classList.toggle('open')">
<span>
<i data-lucide="settings-2" style="width:14px;height:14px;margin-right:8px;vertical-align:middle"></i>
高级参数配置
</span>
<i data-lucide="chevron-down" style="width:14px;height:14px"></i>
</div>
<div class="advanced-config-body">
<!-- 索引增强能力 -->
<div style="margin-bottom:20px">
<label class="form-label" style="margin-bottom:12px">索引增强能力</label>
<div class="parse-method-selector">
<div class="parse-method-option ${formData.enableMetadataExtract ? 'selected' : ''}" onclick="window.__toggleEnhance('metadataExtract')">
<i data-lucide="${formData.enableMetadataExtract ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
Metadata抽取
<span class="help-tip" onclick="window.__showTooltip('metadataTip');event.stopPropagation()">?</span>
</div>
<div class="parse-method-option ${formData.enableExcelHeader ? 'selected' : ''}" onclick="window.__toggleEnhance('excelHeader')">
<i data-lucide="${formData.enableExcelHeader ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
Excel表头拼装
</div>
<div class="parse-method-option ${formData.enableMultiTurn ? 'selected' : ''}" onclick="window.__toggleEnhance('multiTurn')">
<i data-lucide="${formData.enableMultiTurn ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
多轮对话改写
</div>
<div class="parse-method-option ${formData.enableOCR ? 'selected' : ''}" onclick="window.__toggleEnhance('ocr')">
<i data-lucide="${formData.enableOCR ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
OCR增强
</div>
<div class="parse-method-option ${formData.enableAutoKeywords ? 'selected' : ''}" onclick="window.__toggleEnhance('autoKeywords')">
<i data-lucide="${formData.enableAutoKeywords ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
自动关键词提取
</div>
<div class="parse-method-option ${formData.enableAutoTags ? 'selected' : ''}" onclick="window.__toggleEnhance('autoTags')">
<i data-lucide="${formData.enableAutoTags ? 'check-circle' : 'circle'}" style="width:14px;height:14px"></i>
自动标签提取
</div>
</div>
</div>
<!-- 模型配置 -->
<div class="form-group" style="margin-bottom:16px">
<label class="form-label">向量模型</label>
<select class="form-control" id="vectorModel" onchange="formData.vectorModel=this.value;hasUnsavedChanges=true">
<option value="text-embedding-v2" ${formData.vectorModel === 'text-embedding-v2' ? 'selected' : ''}>text-embedding-v2 (推荐)</option>
<option value="text-embedding-3" ${formData.vectorModel === 'text-embedding-3' ? 'selected' : ''}>text-embedding-3</option>
</select>
</div>
<div class="form-group" style="margin-bottom:16px">
<label class="form-label">排序模型</label>
<select class="form-control" id="rerankModel" onchange="formData.rerankModel=this.value;hasUnsavedChanges=true">
<option value="bge-reranker-v2.5" ${formData.rerankModel === 'bge-reranker-v2.5' ? 'selected' : ''}>bge-reranker-v2.5 (推荐)</option>
<option value="bge-reranker-base" ${formData.rerankModel === 'bge-reranker-base' ? 'selected' : ''}>bge-reranker-base</option>
</select>
</div>
<div class="form-group" style="margin-bottom:16px">
<label class="form-label">
排序模式
<span class="help-tip" onclick="window.__showTooltip('rerankModeTip')">?</span>
</label>
<div id="rerankModeTip" class="tooltip-box" style="display:none;margin-bottom:8px">
<div style="padding:12px;font-size:13px;color:#fff">
<p><strong>问答模式</strong>:优先返回适合问答场景的召回结果</p>
<p style="margin-top:4px"><strong>相似模式</strong>:优先返回语义最相近内容</p>
<p style="margin-top:4px"><strong>自定义模式</strong>:允许自定义排序策略与召回规则</p>
</div>
</div>
<select class="form-control" id="rerankMode" onchange="formData.rerankMode=this.value;hasUnsavedChanges=true">
<option value="qa" ${formData.rerankMode === 'qa' ? 'selected' : ''}>问答模式 (推荐)</option>
<option value="similarity" ${formData.rerankMode === 'similarity' ? 'selected' : ''}>相似模式</option>
<option value="custom" ${formData.rerankMode === 'custom' ? 'selected' : ''}>自定义高级模式</option>
</select>
</div>
<!-- 检索参数 -->
<div style="background:var(--bg-base);border-radius:var(--radius);padding:16px;margin-bottom:16px">
<div style="font-size:13px;font-weight:500;margin-bottom:16px">检索参数配置</div>
<div class="form-group" style="margin-bottom:16px">
<label class="form-label">初步向量检索TopK</label>
<div class="range-slider">
<input type="range" min="10" max="100" value="${formData.vectorTopK}"
oninput="window.__updateRangeValue(this,'vectorTopK')">
<span class="range-slider-value" id="vectorTopKValue">${formData.vectorTopK}</span>
<span class="range-slider-hint">(10-100)</span>
</div>
</div>
<div class="form-group" style="margin-bottom:16px">
<label class="form-label">初步关键词检索TopK</label>
<div class="range-slider">
<input type="range" min="10" max="100" value="${formData.keywordTopK}"
oninput="window.__updateRangeValue(this,'keywordTopK')">
<span class="range-slider-value" id="keywordTopKValue">${formData.keywordTopK}</span>
<span class="range-slider-hint">(10-100)</span>
</div>
</div>
<div class="form-group" style="margin-bottom:16px">
<label class="form-label">相似度阈值</label>
<div class="range-slider">
<input type="range" min="1" max="100" value="${formData.similarityThreshold * 100}"
oninput="window.__updateRangeValue(this,'similarityThreshold',100)">
<span class="range-slider-value" id="similarityThresholdValue">${formData.similarityThreshold.toFixed(2)}</span>
<span class="range-slider-hint">(0.01-1)</span>
</div>
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label">最终召回最大数量</label>
<div class="range-slider">
<input type="range" min="1" max="20" value="${formData.maxRecallCount}"
oninput="window.__updateRangeValue(this,'maxRecallCount')">
<span class="range-slider-value" id="maxRecallCountValue">${formData.maxRecallCount}</span>
<span class="range-slider-hint">(1-20)</span>
</div>
</div>
</div>
<button class="btn btn-ghost btn-sm" onclick="window.__resetRetrievalParams()" style="margin-top:8px">
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
恢复默认
</button>
</div>
</div>
<!-- 向量存储 -->
<div class="form-group" style="margin-top:20px">
<label class="form-label">向量存储</label>
<select class="form-control" id="vectorStorage" onchange="formData.vectorStorage=this.value;hasUnsavedChanges=true">
<option value="platform" ${formData.vectorStorage === 'platform' ? 'selected' : ''}>平台默认向量存储 (推荐)</option>
</select>
</div>
<div class="save-draft-hint">
<i data-lucide="save" style="width:12px;height:12px;vertical-align:middle"></i>
系统会自动保存草稿,您可以随时中断并继续
</div>
</div>
`;
}
/**
* 渲染切片配置
*/
function renderSliceConfig() {
switch (formData.sliceMethod) {
case 'smart':
return `
<div class="slice-config-row">
<div class="slice-config-label">
最大分段长度
<span class="help-tip" onclick="window.__showTooltip('maxLengthTip')">?</span>
</div>
<div class="slice-config-control">
<input type="number" class="form-control" style="width:100px" id="maxChunkLength"
value="${formData.maxChunkLength}" min="10" max="6000"
onchange="formData.maxChunkLength=parseInt(this.value);hasUnsavedChanges=true">
<span style="font-size:13px;color:var(--text-muted)">字符 (10-6000)</span>
</div>
</div>
<div id="maxLengthTip" class="tooltip-box" style="display:none;margin:8px 0">
<div style="padding:12px;font-size:13px;color:#fff">智能切分时每个切片的最大字符数。</div>
</div>
`;
case 'length':
return `
<div class="slice-config-row">
<div class="slice-config-label">
分段预估长度
<span class="help-tip" onclick="window.__showTooltip('chunkLengthTip')">?</span>
</div>
<div class="slice-config-control">
<input type="number" class="form-control" style="width:100px" id="maxChunkLength"
value="${formData.maxChunkLength}" min="10" max="6000"
onchange="formData.maxChunkLength=parseInt(this.value);hasUnsavedChanges=true">
<span style="font-size:13px;color:var(--text-muted)">字符 (10-6000)</span>
</div>
</div>
<div id="chunkLengthTip" class="tooltip-box" style="display:none;margin:8px 0">
<div style="padding:12px;font-size:13px;color:#fff">每个切片的目标字符数。</div>
</div>
<div class="slice-config-row">
<div class="slice-config-label">
分段重叠长度
<span class="help-tip" onclick="window.__showTooltip('overlapTip')">?</span>
</div>
<div class="slice-config-control">
<input type="number" class="form-control" style="width:100px" id="overlapLength"
value="${formData.overlapLength}" min="0" max="1024"
onchange="formData.overlapLength=parseInt(this.value);hasUnsavedChanges=true">
<span style="font-size:13px;color:var(--text-muted)">字符 (0-1024)</span>
</div>
</div>
<div id="overlapTip" class="tooltip-box" style="display:none;margin:8px 0">
<div style="padding:12px;font-size:13px;color:#fff">相邻切片之间的重叠字符数,用于保证上下文连贯性。</div>
</div>
`;
case 'page':
return `
<div class="slice-config-row">
<div class="slice-config-label">
最大分段长度
</div>
<div class="slice-config-control">
<input type="number" class="form-control" style="width:100px" id="maxChunkLength"
value="${formData.maxChunkLength}" min="10" max="6000"
onchange="formData.maxChunkLength=parseInt(this.value);hasUnsavedChanges=true">
<span style="font-size:13px;color:var(--text-muted)">字符 (10-6000)</span>
</div>
</div>
`;
case 'title':
return `
<div class="slice-config-row">
<div class="slice-config-label">最大分段长度</div>
<div class="slice-config-control">
<input type="number" class="form-control" style="width:100px" id="maxChunkLength"
value="${formData.maxChunkLength}" min="10" max="6000"
onchange="formData.maxChunkLength=parseInt(this.value);hasUnsavedChanges=true">
<span style="font-size:13px;color:var(--text-muted)">字符 (10-6000)</span>
</div>
</div>
<div class="slice-config-row">
<div class="slice-config-label">标题级数</div>
<div class="slice-config-control">
<input type="number" class="form-control" style="width:100px" id="titleLevel"
value="${formData.titleLevel}" min="1" max="5"
onchange="formData.titleLevel=parseInt(this.value);hasUnsavedChanges=true">
<span style="font-size:13px;color:var(--text-muted)">级 (1-5)</span>
</div>
</div>
`;
case 'regex':
return `
<div class="slice-config-row">
<div class="slice-config-label">正则表达式</div>
<div class="slice-config-control">
<input type="text" class="form-control" id="regexPattern"
value="${escapeHtml(formData.regexPattern)}" placeholder="例如: \\n\\n"
style="width:200px"
onchange="formData.regexPattern=this.value;hasUnsavedChanges=true">
</div>
</div>
<div class="slice-config-row">
<div class="slice-config-label">最大分段长度</div>
<div class="slice-config-control">
<input type="number" class="form-control" style="width:100px" id="maxChunkLength"
value="${formData.maxChunkLength}" min="10" max="6000"
onchange="formData.maxChunkLength=parseInt(this.value);hasUnsavedChanges=true">
<span style="font-size:13px;color:var(--text-muted)">字符 (10-6000)</span>
</div>
</div>
`;
case 'symbol':
return `
<div class="slice-config-row">
<div class="slice-config-label">符号选择器</div>
<div class="slice-config-control">
<select class="form-control" id="symbolChars" style="width:200px"
onchange="formData.symbolChars=this.value;hasUnsavedChanges=true">
${symbolOptions.map(s => `
<option value="${s.value}" ${formData.symbolChars === s.value ? 'selected' : ''}>${s.label}</option>
`).join('')}
</select>
</div>
</div>
<div class="slice-config-row">
<div class="slice-config-label">最大分段长度</div>
<div class="slice-config-control">
<input type="number" class="form-control" style="width:100px" id="maxChunkLength"
value="${formData.maxChunkLength}" min="10" max="6000"
onchange="formData.maxChunkLength=parseInt(this.value);hasUnsavedChanges=true">
<span style="font-size:13px;color:var(--text-muted)">字符 (10-6000)</span>
</div>
</div>
`;
default:
return '';
}
}
// ==================== 渲染页面 ====================
function renderPage() {
container.innerHTML = `
<div class="kb-create-wizard">
<!-- 页面头部 -->
<div class="page-header" style="margin-bottom:32px">
<div class="kb-list-title">
<button class="page-back-btn" data-tooltip="返回列表" onclick="window.__confirmLeave()">
<i data-lucide="arrow-left" style="width:16px;height:16px"></i>
</button>
<div>
<h1 class="page-title">创建知识库</h1>
<p class="page-desc">通过向导创建新的知识库,配置数据源和索引参数</p>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-outline" onclick="window.__saveDraft();return false">
<i data-lucide="save" style="width:14px;height:14px"></i>
保存草稿
</button>
<button class="btn btn-ghost" onclick="window.__confirmLeave()">
取消
</button>
</div>
</div>
${renderWizardSteps()}
<div id="wizardContent">
${currentStep === 1 ? renderStep1() : currentStep === 2 ? renderStep2() : renderStep3()}
</div>
<div class="wizard-footer">
<div class="wizard-footer-left">
${currentStep > 1 ? `
<button class="btn btn-outline" onclick="window.__prevStep()">
<i data-lucide="arrow-left" style="width:14px;height:14px"></i>
上一步
</button>
` : ''}
</div>
<div class="wizard-footer-right">
${currentStep < totalSteps ? `
<button class="btn btn-outline" onclick="window.__nextStep()">
下一步
<i data-lucide="arrow-right" style="width:14px;height:14px"></i>
</button>
` : `
<button class="btn btn-primary" onclick="window.__finishWizard()">
<i data-lucide="check" style="width:14px;height:14px"></i>
完成创建
</button>
`}
</div>
</div>
</div>
`;
lucide.createIcons({ elements: [container] });
}
// ==================== 步骤控制 ====================
function collectStepData() {
if (currentStep === 1) {
formData.name = document.getElementById('kbName')?.value.trim() || '';
formData.description = document.getElementById('kbDesc')?.value.trim() || '';
}
}
window.__prevStep = function() {
if (currentStep > 1) {
collectStepData();
currentStep--;
renderPage();
}
};
window.__nextStep = function() {
collectStepData();
let errors = [];
if (currentStep === 1) {
errors = validateStep1();
} else if (currentStep === 2) {
errors = validateStep2();
}
if (errors.length > 0) {
Toast.error(errors[0]);
return;
}
if (currentStep < totalSteps) {
currentStep++;
renderPage();
}
};
window.__finishWizard = function() {
collectStepData();
// TODO: 调用 API POST /api/v1/knowledge-bases 创建知识库
console.log('创建知识库数据:', formData);
const typeMap = {
doc_search: '文档搜索',
data_query: '数据查询',
image_qa: '图片问答',
video_search: '音视频搜索',
};
const createdAt = new Date();
const pad = value => String(value).padStart(2, '0');
const createdText = `${createdAt.getFullYear()}-${pad(createdAt.getMonth() + 1)}-${pad(createdAt.getDate())} ${pad(createdAt.getHours())}:${pad(createdAt.getMinutes())}:${pad(createdAt.getSeconds())}`;
const uploadedCount = Array.isArray(formData.uploadedFiles) ? formData.uploadedFiles.length : 0;
const newKb = {
id: `kb-${Date.now()}`,
name: formData.name || '未命名知识库',
description: formData.description || '暂无描述',
docs: uploadedCount * 24,
size: uploadedCount ? `${Math.max(1, uploadedCount * 8)}MB` : '0MB',
type: formData.type || 'doc_search',
typeName: typeMap[formData.type] || '文档搜索',
status: uploadedCount ? 'processing' : 'ready',
createdAt: createdText,
updatedAt: createdText,
tags: [],
};
const catalog = Array.isArray(window.__knowledgeBaseCatalog)
? window.__knowledgeBaseCatalog
: [];
catalog.unshift(newKb);
window.__knowledgeBaseCatalog = catalog;
clearDraft();
Toast.success('知识库创建成功!');
setTimeout(() => {
navigateTo('knowledge-base');
}, 1000);
};
// ==================== 全局函数 ====================
window.__updateCharCount = function(input, max) {
const counter = input.closest('.form-group')?.querySelector('.char-counter');
if (counter) {
counter.textContent = `${input.value.length}/${max}`;
counter.classList.toggle('warning', input.value.length >= max * 0.9);
}
hasUnsavedChanges = true;
};
window.__selectKbType = function(typeId) {
formData.type = typeId;
hasUnsavedChanges = true;
document.querySelectorAll('.kb-type-card').forEach(card => {
card.classList.toggle('selected', card.dataset.type === typeId);
});
};
window.__showKbTypeHelp = function() {
createModal({
title: '知识库类型说明',
content: `
<div class="help-modal-content">
${kbTypes.map(type => `
<div class="help-modal-item">
<div class="help-modal-item-title">
<div class="help-modal-item-icon">
<i data-lucide="${type.icon}" style="width:20px;height:20px"></i>
</div>
${type.title}
</div>
<div class="help-modal-item-desc">${type.tooltip}</div>
</div>
`).join('')}
</div>
`,
confirmText: '',
cancelText: '关闭'
});
lucide.createIcons();
};
window.__selectSourceType = function(type) {
formData.sourceType = type;
hasUnsavedChanges = true;
const formats = getFormatsByType();
const maxFiles = 50;
const maxSize = formData.type === 'image_qa' || formData.type === 'video_search' ? '20MB' : '50MB';
document.getElementById('sourceContent').innerHTML = renderSourceContent(formats, maxFiles, maxSize);
lucide.createIcons();
};
window.__handleDragOver = function(e) {
e.preventDefault();
e.currentTarget.classList.add('dragover');
};
window.__handleDragLeave = function(e) {
e.currentTarget.classList.remove('dragover');
};
window.__handleDrop = function(e) {
e.preventDefault();
e.currentTarget.classList.remove('dragover');
window.__handleFileSelect(e.dataTransfer.files);
};
window.__handleFileSelect = function(files) {
if (!files || !files.length) return;
const maxFiles = 50;
const maxSize = formData.type === 'image_qa' || formData.type === 'video_search' ? 20 * 1024 * 1024 : 50 * 1024 * 1024;
Array.from(files).forEach(file => {
if (formData.uploadedFiles.length >= maxFiles) {
Toast.warning(`最多只能上传${maxFiles}个文件`);
return;
}
if (file.size > maxSize) {
Toast.warning(`文件 ${file.name} 超过大小限制 (${maxSize / 1024 / 1024}MB)`);
return;
}
const fileObj = {
name: file.name,
size: (file.size / 1024 / 1024).toFixed(2) + 'MB',
status: 'uploading',
file: file
};
formData.uploadedFiles.push(fileObj);
hasUnsavedChanges = true;
// TODO: 调用 API POST /api/v1/files/upload 上传文件
// 模拟上传进度
setTimeout(() => {
fileObj.status = 'processing';
renderPage();
}, 500);
setTimeout(() => {
// 模拟90%成功率
fileObj.status = Math.random() > 0.1 ? 'ready' : 'error';
renderPage();
}, 2000);
});
renderPage();
};
window.__removeFile = function(index) {
formData.uploadedFiles.splice(index, 1);
hasUnsavedChanges = true;
renderPage();
};
window.__retryUpload = function(index) {
if (formData.uploadedFiles[index]) {
const fileObj = formData.uploadedFiles[index];
fileObj.status = 'uploading';
renderPage();
setTimeout(() => {
fileObj.status = 'processing';
renderPage();
}, 500);
setTimeout(() => {
fileObj.status = 'ready';
renderPage();
Toast.success(`${fileObj.name} 已重新上传并解析完成`);
}, 1500);
}
};
window.__clearAllFiles = function() {
formData.uploadedFiles = [];
hasUnsavedChanges = true;
renderPage();
};
window.__updateCategory = function(value) {
formData.category = value;
hasUnsavedChanges = true;
};
window.__selectParseMethod = function(method) {
formData.parseMethod = method;
hasUnsavedChanges = true;
document.querySelectorAll('.parse-method-option').forEach(opt => {
const isSelected = opt.textContent.includes(
method === 'inherit' ? '继承' : method === 'default' ? '默认' : '自定义'
);
opt.classList.toggle('selected', isSelected);
});
const detail = document.getElementById('parseMethodDetail');
if (detail) {
detail.innerHTML = renderParseMethodDetail();
}
};
window.__toggleParseMethod = function(type, method) {
const key = type === 'doc' ? 'docParseMethods' : type === 'image' ? 'imageParseMethods' : 'videoParseMethods';
const arr = formData[key];
const idx = arr.indexOf(method);
if (idx > -1) {
arr.splice(idx, 1);
} else {
arr.push(method);
}
hasUnsavedChanges = true;
const detail = document.getElementById('parseMethodDetail');
if (detail) {
detail.innerHTML = renderParseMethodDetail();
lucide.createIcons({ elements: [detail] });
}
};
window.__toggleAutoSync = function(checked) {
formData.autoSync = checked;
hasUnsavedChanges = true;
Toast.info(checked ? '已开启自动同步' : '已关闭自动同步');
};
window.__updateSliceConfig = function() {
formData.sliceMethod = document.getElementById('sliceMethod').value;
hasUnsavedChanges = true;
document.getElementById('sliceConfigPanel').innerHTML = renderSliceConfig();
lucide.createIcons();
};
window.__updateRangeValue = function(input, field, multiplier = 1) {
const value = parseInt(input.value);
const displayEl = document.getElementById(field + 'Value');
if (displayEl) {
displayEl.textContent = multiplier > 1 ? (value / multiplier).toFixed(2) : value;
}
formData[field] = multiplier > 1 ? value / multiplier : value;
hasUnsavedChanges = true;
};
window.__resetRetrievalParams = function() {
formData.vectorTopK = 60;
formData.keywordTopK = 60;
formData.similarityThreshold = 0.5;
formData.maxRecallCount = 10;
hasUnsavedChanges = true;
renderPage();
};
window.__toggleEnhance = function(key) {
formData['enable' + key.charAt(0).toUpperCase() + key.slice(1)] = !formData['enable' + key.charAt(0).toUpperCase() + key.slice(1)];
hasUnsavedChanges = true;
const advancedBody = document.querySelector('.advanced-config-body');
if (advancedBody) {
const options = advancedBody.querySelectorAll('.parse-method-option');
options.forEach(opt => {
if (opt.textContent.includes('Metadata抽取')) {
opt.classList.toggle('selected', formData.enableMetadataExtract);
} else if (opt.textContent.includes('Excel表头')) {
opt.classList.toggle('selected', formData.enableExcelHeader);
} else if (opt.textContent.includes('多轮对话')) {
opt.classList.toggle('selected', formData.enableMultiTurn);
} else if (opt.textContent.includes('OCR')) {
opt.classList.toggle('selected', formData.enableOCR);
} else if (opt.textContent.includes('关键词')) {
opt.classList.toggle('selected', formData.enableAutoKeywords);
} else if (opt.textContent.includes('标签')) {
opt.classList.toggle('selected', formData.enableAutoTags);
}
});
}
};
window.__showTooltip = function(id) {
const tooltip = document.getElementById(id);
if (tooltip) {
tooltip.style.display = tooltip.style.display === 'none' ? 'block' : 'none';
}
};
window.__toggleFileSelect = function(el, filename) {
const checkbox = el.querySelector('input[type="checkbox"]');
checkbox.checked = !checkbox.checked;
el.classList.toggle('selected', checkbox.checked);
Toast.info(checkbox.checked ? `已选择: ${filename}` : `已取消: ${filename}`);
};
window.__loadCategoryFiles = function(category) {
// TODO: 调用 API GET /api/v1/categories/:id/files
createModal({
title: `${category} 文件列表`,
size: 'lg',
content: `
<div style="display:flex;flex-direction:column;gap:10px">
${['儿童科学百科.pdf', '科学小实验.docx', '物理入门.xlsx'].map((name, index) => `
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid var(--border);border-radius:var(--radius)">
<div>
<div style="font-weight:600">${name}</div>
<div style="font-size:12px;color:var(--text-muted)">mock-file-${index + 1} · ${(index + 1) * 2}.4MB</div>
</div>
<button class="btn btn-outline btn-sm" onclick="Toast.success('已选择 ${name}')">选择</button>
</div>
`).join('')}
</div>
`,
confirmText: '',
cancelText: '关闭'
});
};
window.__showCategoryDrawer = function() {
const renderCategoryRows = () => {
const list = document.getElementById('categoryManageList');
if (!list) return;
list.innerHTML = categories.map(category => `
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid var(--border);border-radius:var(--radius)">
<div>
<div style="font-weight:600">${category.name}</div>
<div style="font-size:12px;color:var(--text-muted)">${category.fileCount} 个文件 · ${category.id}</div>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-outline btn-sm" onclick="window.__chooseCategoryFromModal('${category.name}')">选择</button>
${category.id === 'cat-001' ? '' : `
<button class="btn btn-ghost btn-sm" onclick="window.__removeCategoryFromModal('${category.id}')">
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
</button>
`}
</div>
</div>
`).join('');
lucide.createIcons({ elements: list });
};
createModal({
title: '类目管理',
size: 'lg',
content: `
<div style="display:flex;gap:10px;margin-bottom:14px">
<input class="form-control" id="newCategoryName" placeholder="输入新类目名称">
<button class="btn btn-primary" type="button" onclick="window.__addCategoryFromModal()">添加</button>
</div>
<div id="categoryManageList" style="display:flex;flex-direction:column;gap:10px"></div>
`,
confirmText: '',
cancelText: '关闭'
});
window.__addCategoryFromModal = function() {
const input = document.getElementById('newCategoryName');
const name = input?.value.trim();
if (!name) {
Toast.warning('请输入类目名称');
return;
}
categories.push({
id: `cat-${Date.now()}`,
name,
fileCount: 0,
});
input.value = '';
renderCategoryRows();
Toast.success('类目已添加');
};
window.__removeCategoryFromModal = function(id) {
const index = categories.findIndex(category => category.id === id);
if (index !== -1) {
categories.splice(index, 1);
renderCategoryRows();
Toast.success('类目已删除');
}
};
window.__chooseCategoryFromModal = function(name) {
formData.category = name;
hasUnsavedChanges = true;
const select = document.getElementById('categorySelect');
if (select && Array.from(select.options).some(option => option.value === name)) {
select.value = name;
}
Toast.success(`已选择类目:${name}`);
};
setTimeout(renderCategoryRows, 50);
};
window.__saveDraft = saveDraft;
window.__confirmLeave = function() {
if (hasUnsavedChanges) {
createModal({
title: '确认离开',
content: `
<div class="confirm-leave-modal">
<p>确定要离开吗?</p>
<p class="warning-text">您有未保存的更改,离开后将丢失。</p>
</div>
`,
confirmText: '离开',
confirmClass: 'btn-danger',
onConfirm: () => {
navigateTo('knowledge-base');
}
});
} else {
navigateTo('knowledge-base');
}
};
// ==================== 初始化 ====================
loadDraft();
renderPage();
// 离开页面确认
window.addEventListener('beforeunload', function(e) {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});
});