/**
* 📤 文档上传面板
* 支持 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
上传进度:{{ uploadProgress }}%
POST/upload/file(Tika 多格式解析)
{{ validationErrors.file }}
POST/upload/markdown
{{ validationErrors.markdown }}
POST/upload/json/basic(整体解析)
{{ validationErrors.jsonBasic }}
POST/upload/json/fields(按字段名提取)
{{ validationErrors.jsonFields }}
POST/upload/json/pointer(JSON Pointer 路径拆分)
{{ 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
}
}
}