/** * 📤 文档上传面板 * 支持 6 种上传格式 + 前端校验 + 上传进度 + 分块配置 */ import { ref, reactive } from 'vue' import { store } from '../js/store.js' import { uploadFile, uploadString, uploadMarkdown, uploadJsonBasic, uploadJsonFields, uploadJsonPointer } from '../js/api.js' import { toast, formatBytes } from '../js/utils.js' // ==================== 上传校验常量 ==================== /** 允许上传的文件类型白名单 */ const ALLOWED_EXTENSIONS = new Set([ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'json', 'csv', 'html', 'xml', 'rtf' ]) /** 文件大小上限 50MB */ const MAX_FILE_SIZE = 50 * 1024 * 1024 export default { template: `

文档上传

上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector

留空则使用全局配置(分块大小 200 Token,重叠 100 Token)

Token
上传进度:{{ uploadProgress }}%
POST/upload/file(Tika 多格式解析)

点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)

{{ validationErrors.file }}
POST/upload/string(直接上传文本)
POST/upload/markdown

点击或拖拽上传,支持多文件(Markdown .md)

{{ validationErrors.markdown }}
POST/upload/json/basic(整体解析)

点击或拖拽上传,支持多文件(JSON .json)

{{ validationErrors.jsonBasic }}
POST/upload/json/fields(按字段名提取)

点击或拖拽上传,支持多文件(JSON .json)

{{ validationErrors.jsonFields }}
POST/upload/json/pointer(JSON Pointer 路径拆分)

点击或拖拽上传,支持多文件(JSON .json)

{{ validationErrors.jsonPointer }}
`, setup() { const activeSubTab = ref('file') const uploadCategory = ref('') const uploadTags = ref('') const stringTitle = ref('') const stringContent = ref('') const jsonFieldsStr = ref('') const jsonPointerStr = ref('') const showAdvanced = ref(false) const chunkSizeOverride = ref(null) const overlapOverride = ref(null) const uploadProgress = ref(-1) // 文件 input 的 ref 引用(Composition API 需要声明才能在模板中使用) const fileInput = ref(null) const mdInput = ref(null) const jsonBInput = ref(null) const jsonFInput = ref(null) const jsonPInput = ref(null) const fileData = reactive({ file: null, markdown: null, jsonBasic: null, jsonFields: null, jsonPointer: null }) const fileInfo = reactive({ file: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' }) const results = reactive({ file: '', string: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' }) const validationErrors = reactive({ file: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' }) // 添加调试用的计算属性,检查按钮状态 const getButtonState = (type) => { const hasFile = !!fileData[type] const hasError = !!validationErrors[type] console.log(`[BUTTON STATE] type=${type}, hasFile=${hasFile}, hasError=${hasError}, disabled=${!hasFile || hasError}`) return !hasFile || hasError } const subTabs = [ { key: 'file', icon: '📎', label: '通用文件' }, { key: 'string', icon: '📝', label: '文本内容' }, { key: 'markdown', icon: '📑', label: 'Markdown' }, { key: 'jsonBasic', icon: '📋', label: 'JSON 基本' }, { key: 'jsonFields', icon: '🔑', label: 'JSON 按字段' }, { key: 'jsonPointer', icon: '📍', label: 'JSON 按指针' } ] /** * 校验文件列表,返回错误信息或空字符串 */ function validateFiles(files) { for (const f of files) { // 文件大小校验 if (f.size > MAX_FILE_SIZE) { return `文件"${f.name}"超过 50MB 大小限制` } // 文件类型校验 const ext = f.name.includes('.') ? f.name.substring(f.name.lastIndexOf('.') + 1).toLowerCase() : '' if (ext && !ALLOWED_EXTENSIONS.has(ext)) { return `文件类型".${ext}"不在允许列表中` } } return '' } function handleFileSelect(event, type) { const input = event.target if (!input.files || input.files.length === 0) return const files = Array.from(input.files) console.log('[DEBUG] handleFileSelect - type:', type, 'files:', files.length) // 前端校验 const error = validateFiles(files) console.log('[DEBUG] validation error:', error) if (error) { // 校验不通过:清空数据,保持按钮禁用 validationErrors[type] = error fileData[type] = null fileInfo[type] = `${error}` toast(error, 'error') return } // 校验通过 validationErrors[type] = '' fileData[type] = files const totalSize = files.reduce((s, f) => s + f.size, 0) const label = files.length > 1 ? `已选择 ${files.length} 个文件(共 ${formatBytes(totalSize)})` : `已选择:${files[0].name} (${formatBytes(files[0].size)})` fileInfo[type] = label console.log('[DEBUG] fileData[type] set to:', fileData[type]) console.log('[DEBUG] validationErrors[type] set to:', validationErrors[type]) console.log('[DEBUG] Button should be enabled:', !!fileData[type] && !validationErrors[type]) } function handleDrop(event, type) { event.currentTarget.classList.remove('drag-over') const dt = new DataTransfer() for (const f of event.dataTransfer.files) dt.items.add(f) const fakeEvent = { target: { files: dt.files } } handleFileSelect(fakeEvent, type) } /** * 构建分块参数的 query string */ function chunkParams() { const params = [] if (chunkSizeOverride.value) params.push(`chunkSize=${chunkSizeOverride.value}`) if (overlapOverride.value) params.push(`overlap=${overlapOverride.value}`) return params.length > 0 ? (params.join('&')) : '' } /** * 将分块参数附加到 URL */ function appendChunkParams(url) { const cp = chunkParams() if (!cp) return url return url + (url.includes('?') ? '&' : '?') + cp } async function doUpload(type) { const catId = uploadCategory.value const tagsStr = uploadTags.value.trim() uploadProgress.value = -1 // 文本内容上传 if (type === 'string') { if (!stringContent.value.trim()) { toast('请输入文本内容', 'error') return } try { const title = stringTitle.value.trim() || stringContent.value.trim().substring(0, 30) let url = `/upload/string?title=${encodeURIComponent(title)}` if (catId) url += `&categoryId=${catId}` if (tagsStr) url += `&tags=${encodeURIComponent(tagsStr)}` url = appendChunkParams(url) const { default: { API_BASE } } = await import('../js/utils.js') const res = await fetch(API_BASE + url, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: stringContent.value }) const json = await res.json() if (json.success) { results.string = `
${json.message} | 分块数:${json.data.chunkCount} | 状态:${json.data.status}
` toast(json.message, 'success') store.loadCategories() store.loadStats() } else { const isDuplicate = json.message && json.message.includes('重复') results.string = `
${json.message}
` if (!isDuplicate) toast(json.message, 'error') } } catch (e) { results.string = `
上传失败:${e.message}
` toast('上传失败:' + e.message, 'error') } return } // 文件类上传 const files = fileData[type] if (!files || files.length === 0) { toast('请先选择文件', 'error') return } let successCount = 0, failCount = 0 const resultsHtml = [] for (let i = 0; i < files.length; i++) { const file = files[i] uploadProgress.value = files.length > 1 ? Math.round((i / files.length) * 100) : 0 try { const formData = new FormData() formData.append('file', file) if (catId) formData.append('categoryId', catId) if (tagsStr) formData.append('tags', tagsStr) let json const onProgress = files.length === 1 ? (p) => { uploadProgress.value = p } : null switch (type) { case 'file': json = await uploadFile(formData, onProgress) break case 'markdown': json = await uploadMarkdown(formData, onProgress) break case 'jsonBasic': json = await uploadJsonBasic(formData, onProgress) break case 'jsonFields': { if (!jsonFieldsStr.value.trim()) { toast('请输入要提取的字段名', 'error') uploadProgress.value = -1 return } json = await uploadJsonFields(formData, jsonFieldsStr.value.trim(), onProgress) break } case 'jsonPointer': { if (!jsonPointerStr.value.trim()) { toast('请输入 JSON Pointer 路径', 'error') uploadProgress.value = -1 return } json = await uploadJsonPointer(formData, jsonPointerStr.value.trim(), onProgress) break } } if (json.success) { successCount++ resultsHtml.push(`${file.name} — ${json.data.chunkCount || 0} 分块`) } else { failCount++ const isDuplicate = json.message && json.message.includes('重复') resultsHtml.push(`${file.name} — ${json.message || '错误'}`) } } catch (e) { failCount++ resultsHtml.push(`${file.name} — ${e.message}`) } } uploadProgress.value = 100 setTimeout(() => { uploadProgress.value = -1 }, 1500) const summary = successCount > 0 ? `上传完成:成功 ${successCount} 个${failCount > 0 ? `,失败 ${failCount} 个` : ''}` : `全部失败(${failCount} 个文件)` results[type] = `
${summary}
${resultsHtml.join('
')}
` toast(summary, successCount > 0 ? 'success' : 'error') if (successCount > 0) { store.loadCategories() store.loadStats() } } return { activeSubTab, uploadCategory, uploadTags, subTabs, stringTitle, stringContent, jsonFieldsStr, jsonPointerStr, showAdvanced, chunkSizeOverride, overlapOverride, uploadProgress, fileInput, mdInput, jsonBInput, jsonFInput, jsonPInput, fileData, fileInfo, results, validationErrors, store, getButtonState, handleFileSelect, handleDrop, doUpload, formatBytes } } }