本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

406 lines
20 KiB

/**
* 📤 文档上传面板
* 支持 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: `
<div class="card">
<h2>文档上传</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:16px;">上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector</p>
<!-- 上传元信息 -->
<div class="input-row" style="padding:12px;background:#fafafa;border-radius:8px;border:1px solid var(--border);margin-bottom:16px;">
<select class="select" v-model="uploadCategory">
<option value="">选择分类(可选)</option>
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<input class="input" v-model="uploadTags" placeholder="标签,逗号分隔(可选)">
</div>
<!-- 高级设置折叠面板 -->
<div style="margin-bottom:16px;">
<button class="btn btn-outline btn-sm" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? '▼ 隐藏高级设置' : '▶ 高级设置(分块参数)' }}
</button>
<div v-show="showAdvanced" style="margin-top:8px;padding:12px;background:#fafafa;border-radius:8px;border:1px solid var(--border);">
<p style="font-size:12px;color:var(--sub);margin-bottom:8px;">留空则使用全局配置(分块大小 200 Token,重叠 100 Token)</p>
<div class="input-row" style="margin-bottom:0;">
<input class="input input-sm" v-model.number="chunkSizeOverride" placeholder="分块大小" type="number" min="50" max="2000">
<input class="input input-sm" v-model.number="overlapOverride" placeholder="重叠大小" type="number" min="0" max="500">
<span style="font-size:12px;color:var(--sub);line-height:38px;">Token</span>
</div>
</div>
</div>
<!-- 子 Tab -->
<div style="display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;border-bottom:1px solid var(--border);padding-bottom:8px;">
<button v-for="t in subTabs" :key="t.key"
:class="['btn', 'btn-sm', activeSubTab === t.key ? 'btn-primary' : '']"
@click="activeSubTab = t.key">{{ t.label }}</button>
</div>
<!-- 上传进度条 -->
<div v-if="uploadProgress >= 0" style="margin-bottom:12px;">
<div style="background:#e5e7eb;border-radius:6px;height:8px;overflow:hidden;">
<div :style="'width:' + uploadProgress + '%;height:100%;background:var(--primary);transition:width .3s;border-radius:6px;'"></div>
</div>
<div style="font-size:12px;color:var(--sub);margin-top:4px;">上传进度:{{ uploadProgress }}%</div>
</div>
<!-- 通用文件 -->
<div v-show="activeSubTab === 'file'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/file</code><span style="font-size:12px;color:var(--sub);">(Tika 多格式解析)</span></div>
<div class="upload-zone" @click="fileInput && fileInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'file')">
<p>点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)</p>
<input type="file" ref="fileInput" style="display:none" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.html,.xml,.rtf" @change="handleFileSelect($event, 'file')">
</div>
<div v-html="fileInfo.file" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.file" style="margin-bottom:8px;font-size:13px;color:var(--danger);">{{ validationErrors.file }}</div>
<button class="btn btn-primary" @click="doUpload('file')" :disabled="getButtonState('file')">上传并向量化</button>
<div v-html="results.file"></div>
</div>
<!-- 文本内容 -->
<div v-show="activeSubTab === 'string'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/string</code><span style="font-size:12px;color:var(--sub);">(直接上传文本)</span></div>
<input class="input" v-model="stringTitle" placeholder="文档标题" style="margin-bottom:8px;">
<textarea class="textarea" v-model="stringContent" placeholder="输入要加入知识库的文本内容...
例如:客服常见问题、财务报销制度、行政办公流程等"></textarea>
<button class="btn btn-primary" style="margin-top:12px;" @click="doUpload('string')">上传并向量化</button>
<div v-html="results.string"></div>
</div>
<!-- Markdown -->
<div v-show="activeSubTab === 'markdown'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/markdown</code></div>
<div class="upload-zone" @click="$refs.mdInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'markdown')">
<p>点击或拖拽上传,支持多文件(Markdown .md)</p>
<input type="file" ref="mdInput" style="display:none" accept=".md" multiple @change="handleFileSelect($event, 'markdown')">
</div>
<div v-html="fileInfo.markdown" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.markdown" style="margin-bottom:8px;font-size:13px;color:var(--danger);">{{ validationErrors.markdown }}</div>
<button class="btn btn-primary" @click="doUpload('markdown')" :disabled="getButtonState('markdown')">上传并向量化</button>
<div v-html="results.markdown"></div>
</div>
<!-- JSON 基本 -->
<div v-show="activeSubTab === 'jsonBasic'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/basic</code><span style="font-size:12px;color:var(--sub);">(整体解析)</span></div>
<div class="upload-zone" @click="$refs.jsonBInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonBasic')">
<p>点击或拖拽上传,支持多文件(JSON .json)</p>
<input type="file" ref="jsonBInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonBasic')">
</div>
<div v-html="fileInfo.jsonBasic" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.jsonBasic" style="margin-bottom:8px;font-size:13px;color:var(--danger);">{{ validationErrors.jsonBasic }}</div>
<button class="btn btn-primary" @click="doUpload('jsonBasic')" :disabled="getButtonState('jsonBasic')">上传并向量化</button>
<div v-html="results.jsonBasic"></div>
</div>
<!-- JSON 按字段 -->
<div v-show="activeSubTab === 'jsonFields'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/fields</code><span style="font-size:12px;color:var(--sub);">(按字段名提取)</span></div>
<div class="upload-zone" @click="$refs.jsonFInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonFields')">
<p>点击或拖拽上传,支持多文件(JSON .json)</p>
<input type="file" ref="jsonFInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonFields')">
</div>
<div v-html="fileInfo.jsonFields" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.jsonFields" style="margin-bottom:8px;font-size:13px;color:var(--danger);">{{ validationErrors.jsonFields }}</div>
<input class="input" v-model="jsonFieldsStr" placeholder="输入要提取的字段名(逗号分隔),如:title,description,content" style="margin-bottom:12px;">
<button class="btn btn-primary" @click="doUpload('jsonFields')" :disabled="getButtonState('jsonFields')">上传并向量化</button>
<div v-html="results.jsonFields"></div>
</div>
<!-- JSON 按指针 -->
<div v-show="activeSubTab === 'jsonPointer'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/pointer</code><span style="font-size:12px;color:var(--sub);">(JSON Pointer 路径拆分)</span></div>
<div class="upload-zone" @click="$refs.jsonPInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonPointer')">
<p>点击或拖拽上传,支持多文件(JSON .json)</p>
<input type="file" ref="jsonPInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonPointer')">
</div>
<div v-html="fileInfo.jsonPointer" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.jsonPointer" style="margin-bottom:8px;font-size:13px;color:var(--danger);">{{ validationErrors.jsonPointer }}</div>
<input class="input" v-model="jsonPointerStr" placeholder="输入 JSON Pointer 路径,如:/data/items 或 /products" style="margin-bottom:12px;">
<button class="btn btn-primary" @click="doUpload('jsonPointer')" :disabled="getButtonState('jsonPointer')">上传并向量化</button>
<div v-html="results.jsonPointer"></div>
</div>
</div>
`,
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] = `<span style="color:var(--danger);">${error}</span>`
toast(error, 'error')
return
}
// 校验通过
validationErrors[type] = ''
fileData[type] = files
const totalSize = files.reduce((s, f) => s + f.size, 0)
const label = files.length > 1
? `已选择 <strong>${files.length}</strong> 个文件(共 ${formatBytes(totalSize)}`
: `已选择:<strong>${files[0].name}</strong> (${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 = `<div style="margin-top:8px;padding:12px;background:#ecfdf5;border-radius:8px;font-size:13px;">${json.message} | 分块数:<strong>${json.data.chunkCount}</strong> | 状态:<strong>${json.data.status}</strong></div>`
toast(json.message, 'success')
store.loadCategories()
store.loadStats()
} else {
const isDuplicate = json.message && json.message.includes('重复')
results.string = `<div style="margin-top:8px;padding:12px;background:${isDuplicate ? '#fef3c7' : '#fef2f2'};border-radius:8px;font-size:13px;color:${isDuplicate ? '#b45309' : 'var(--danger)'};">${json.message}</div>`
if (!isDuplicate) toast(json.message, 'error')
}
} catch (e) {
results.string = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">上传失败:${e.message}</div>`
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(`<span style="color:var(--success);">${file.name}</span> — ${json.data.chunkCount || 0} 分块`)
} else {
failCount++
const isDuplicate = json.message && json.message.includes('重复')
resultsHtml.push(`<span style="color:${isDuplicate ? '#b45309' : 'var(--danger)'};">${file.name}</span> — ${json.message || '错误'}`)
}
} catch (e) {
failCount++
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${e.message}`)
}
}
uploadProgress.value = 100
setTimeout(() => { uploadProgress.value = -1 }, 1500)
const summary = successCount > 0
? `上传完成:成功 ${successCount}${failCount > 0 ? `,失败 ${failCount}` : ''}`
: `全部失败(${failCount} 个文件)`
results[type] = `<div style="margin-top:8px;padding:12px;background:${successCount > 0 ? '#ecfdf5' : '#fef2f2'};border-radius:8px;font-size:13px;">${summary}<br><div style="margin-top:6px;line-height:1.8;">${resultsHtml.join('<br>')}</div></div>`
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
}
}
}