|
|
|
@ -1,12 +1,23 @@ |
|
|
|
/** |
|
|
|
* 📤 文档上传面板 |
|
|
|
* 支持 6 种上传格式:通用文件、文本、Markdown、JSON(3种模式) |
|
|
|
* 支持 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"> |
|
|
|
@ -22,6 +33,21 @@ export default { |
|
|
|
<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:#f9fafb;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" |
|
|
|
@ -29,15 +55,24 @@ export default { |
|
|
|
@click="activeSubTab = t.key">{{ t.icon }} {{ 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="$refs.fileInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'file')"> |
|
|
|
<div class="icon">📎</div><p>点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)</p> |
|
|
|
<input type="file" ref="fileInput" style="display:none" multiple @change="handleFileSelect($event, 'file')"> |
|
|
|
<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> |
|
|
|
<button class="btn btn-primary" @click="doUpload('file')" :disabled="!fileData.file">🚀 上传并向量化</button> |
|
|
|
<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="!fileData.file || validationErrors.file">🚀 上传并向量化</button> |
|
|
|
<div v-html="results.file"></div> |
|
|
|
</div> |
|
|
|
|
|
|
|
@ -59,7 +94,8 @@ export default { |
|
|
|
<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> |
|
|
|
<button class="btn btn-primary" @click="doUpload('markdown')" :disabled="!fileData.markdown">🚀 上传并向量化</button> |
|
|
|
<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="!fileData.markdown || validationErrors.markdown">🚀 上传并向量化</button> |
|
|
|
<div v-html="results.markdown"></div> |
|
|
|
</div> |
|
|
|
|
|
|
|
@ -71,7 +107,8 @@ export default { |
|
|
|
<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> |
|
|
|
<button class="btn btn-primary" @click="doUpload('jsonBasic')" :disabled="!fileData.jsonBasic">🚀 上传并向量化</button> |
|
|
|
<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="!fileData.jsonBasic || validationErrors.jsonBasic">🚀 上传并向量化</button> |
|
|
|
<div v-html="results.jsonBasic"></div> |
|
|
|
</div> |
|
|
|
|
|
|
|
@ -83,8 +120,9 @@ export default { |
|
|
|
<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="!fileData.jsonFields">🚀 上传并向量化</button> |
|
|
|
<button class="btn btn-primary" @click="doUpload('jsonFields')" :disabled="!fileData.jsonFields || validationErrors.jsonFields">🚀 上传并向量化</button> |
|
|
|
<div v-html="results.jsonFields"></div> |
|
|
|
</div> |
|
|
|
|
|
|
|
@ -96,8 +134,9 @@ export default { |
|
|
|
<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="!fileData.jsonPointer">🚀 上传并向量化</button> |
|
|
|
<button class="btn btn-primary" @click="doUpload('jsonPointer')" :disabled="!fileData.jsonPointer || validationErrors.jsonPointer">🚀 上传并向量化</button> |
|
|
|
<div v-html="results.jsonPointer"></div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
@ -110,6 +149,10 @@ export default { |
|
|
|
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) |
|
|
|
|
|
|
|
const fileData = reactive({ |
|
|
|
file: null, markdown: null, jsonBasic: null, jsonFields: null, jsonPointer: null |
|
|
|
@ -123,6 +166,10 @@ export default { |
|
|
|
file: '', string: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' |
|
|
|
}) |
|
|
|
|
|
|
|
const validationErrors = reactive({ |
|
|
|
file: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' |
|
|
|
}) |
|
|
|
|
|
|
|
const subTabs = [ |
|
|
|
{ key: 'file', icon: '📎', label: '通用文件' }, |
|
|
|
{ key: 'string', icon: '📝', label: '文本内容' }, |
|
|
|
@ -132,31 +179,81 @@ export default { |
|
|
|
{ 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 |
|
|
|
fileData[type] = Array.from(input.files) |
|
|
|
const totalSize = fileData[type].reduce((s, f) => s + f.size, 0) |
|
|
|
const label = fileData[type].length > 1 |
|
|
|
? `已选择 <strong>${fileData[type].length}</strong> 个文件(共 ${formatBytes(totalSize)})` |
|
|
|
: `已选择:<strong>${fileData[type][0].name}</strong> (${formatBytes(fileData[type][0].size)})` |
|
|
|
const files = Array.from(input.files) |
|
|
|
|
|
|
|
// 前端校验
|
|
|
|
const error = validateFiles(files) |
|
|
|
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 |
|
|
|
} |
|
|
|
|
|
|
|
function handleDrop(event, type) { |
|
|
|
event.currentTarget.classList.remove('drag-over') |
|
|
|
const refMap = { file: 'fileInput', markdown: 'mdInput', jsonBasic: 'jsonBInput', jsonFields: 'jsonFInput', jsonPointer: 'jsonPInput' } |
|
|
|
// 模拟文件选择
|
|
|
|
const dt = new DataTransfer() |
|
|
|
for (const f of event.dataTransfer.files) dt.items.add(f) |
|
|
|
// 通过 handleFileSelect 处理
|
|
|
|
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') { |
|
|
|
@ -166,14 +263,23 @@ export default { |
|
|
|
} |
|
|
|
try { |
|
|
|
const title = stringTitle.value.trim() || stringContent.value.trim().substring(0, 30) |
|
|
|
const json = await uploadString(stringContent.value, title, catId || undefined, tagsStr || undefined) |
|
|
|
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 { |
|
|
|
results.string = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">${json.message}</div>` |
|
|
|
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>` |
|
|
|
@ -194,6 +300,7 @@ export default { |
|
|
|
|
|
|
|
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) |
|
|
|
@ -201,30 +308,34 @@ export default { |
|
|
|
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) |
|
|
|
json = await uploadFile(formData, onProgress) |
|
|
|
break |
|
|
|
case 'markdown': |
|
|
|
json = await uploadMarkdown(formData) |
|
|
|
json = await uploadMarkdown(formData, onProgress) |
|
|
|
break |
|
|
|
case 'jsonBasic': |
|
|
|
json = await uploadJsonBasic(formData) |
|
|
|
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()) |
|
|
|
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()) |
|
|
|
json = await uploadJsonPointer(formData, jsonPointerStr.value.trim(), onProgress) |
|
|
|
break |
|
|
|
} |
|
|
|
} |
|
|
|
@ -234,7 +345,8 @@ export default { |
|
|
|
resultsHtml.push(`<span style="color:var(--success);">${file.name}</span> — ${json.data.chunkCount || 0} 分块`) |
|
|
|
} else { |
|
|
|
failCount++ |
|
|
|
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${json.message || '错误'}`) |
|
|
|
const isDuplicate = json.message && json.message.includes('重复') |
|
|
|
resultsHtml.push(`<span style="color:${isDuplicate ? '#b45309' : 'var(--danger)'};">${file.name}</span> — ${json.message || '错误'}`) |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
failCount++ |
|
|
|
@ -242,6 +354,9 @@ export default { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
uploadProgress.value = 100 |
|
|
|
setTimeout(() => { uploadProgress.value = -1 }, 1500) |
|
|
|
|
|
|
|
const summary = successCount > 0 |
|
|
|
? `上传完成:成功 ${successCount} 个${failCount > 0 ? `,失败 ${failCount} 个` : ''}` |
|
|
|
: `全部失败(${failCount} 个文件)` |
|
|
|
@ -257,7 +372,9 @@ export default { |
|
|
|
return { |
|
|
|
activeSubTab, uploadCategory, uploadTags, subTabs, |
|
|
|
stringTitle, stringContent, jsonFieldsStr, jsonPointerStr, |
|
|
|
fileData, fileInfo, results, store, |
|
|
|
showAdvanced, chunkSizeOverride, overlapOverride, |
|
|
|
uploadProgress, |
|
|
|
fileData, fileInfo, results, validationErrors, store, |
|
|
|
handleFileSelect, handleDrop, doUpload, formatBytes |
|
|
|
} |
|
|
|
} |
|
|
|
|