/** * 📤 文档上传面板 * 支持 6 种上传格式 + 前端校验 + 上传进度 + 分块配置 */ import { ref, reactive, computed } from 'vue' import { store } from '../js/store.js' import { uploadFile, uploadString, uploadMarkdown, uploadJsonBasic, uploadJsonFields, uploadJsonPointer } from '../js/api.js' import { toast, formatBytes, authHeaders, API_BASE } 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

{{ tag }}×
{{ s.tag }} ({{ s.count }})

留空则使用全局配置(分块大小 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 tagInputRef = ref(null) // P1-2.3: 标签输入状态 const tagList = ref([]) const tagInputValue = ref('') const tagInputFocused = ref(false) const filteredSuggestions = computed(() => { const input = tagInputValue.value.trim().toLowerCase() if (!input) return (store.tags || []).slice(0, 10) return (store.tags || []) .filter(t => t.tag.toLowerCase().includes(input) && !tagList.value.includes(t.tag)) .slice(0, 8) }) function addTag() { const val = tagInputValue.value.trim().replace(/,/g, '') if (val && !tagList.value.includes(val)) { tagList.value.push(val) } tagInputValue.value = '' } function removeTag(idx) { tagList.value.splice(idx, 1) } function selectSuggestion(tag) { if (!tagList.value.includes(tag)) { tagList.value.push(tag) } tagInputValue.value = '' tagInputFocused.value = false } function onTagInput() { // 输入时自动显示建议列表 } function onTagBlur() { // 延迟关闭建议列表,让 mousedown 事件先触发 setTimeout(() => { tagInputFocused.value = false }, 200) } 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) => { return !fileData[type] || !!validationErrors[type] } 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) // 前端校验 const error = validateFiles(files) 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 } 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 // P1-2.3: 使用标签列表替代逗号分隔输入 const tagsStr = tagList.value.length > 0 ? tagList.value.join(',') : 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 res = await fetch(API_BASE + url, { method: 'POST', headers: { 'Content-Type': 'text/plain', ...authHeaders() }, body: stringContent.value }) const json = await res.json() if (json.success) { const isProcessing = json.data && json.data.status === 'PROCESSING' const statusText = isProcessing ? '后台处理中...' : `分块数:${json.data.chunkCount} | 状态:已完成` results.string = `
${json.message} | ${statusText}
` 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) // P1-2.4: 传递 per-doc 分块参数 if (chunkSizeOverride.value) formData.append('chunkSize', chunkSizeOverride.value) if (overlapOverride.value) formData.append('overlap', overlapOverride.value) 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++ const isProcessing = json.data && json.data.status === 'PROCESSING' const chunkInfo = isProcessing ? '后台处理中...' : `${json.data.chunkCount || 0} 分块` resultsHtml.push(`${file.name} — ${chunkInfo}`) } 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, tagInputRef, tagList, tagInputValue, tagInputFocused, filteredSuggestions, addTag, removeTag, selectSuggestion, onTagInput, onTagBlur, fileData, fileInfo, results, validationErrors, store, getButtonState, handleFileSelect, handleDrop, doUpload, formatBytes } } }