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
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
|
|
}
|
|
}
|
|
}
|