ForcePilot/web/src/components/FileUploadModal.vue

739 lines
19 KiB
Vue
Raw Normal View History

<template>
<a-modal
v-model:open="visible"
title="添加文件"
width="800px"
@cancel="handleCancel"
>
<template #footer>
<a-button key="back" @click="handleCancel">取消</a-button>
<a-button
key="submit"
type="primary"
@click="chunkData"
:loading="chunkLoading"
:disabled="fileList.length === 0"
>
添加到知识库
</a-button>
</template>
<div class="add-files-content">
<div class="upload-header">
<div class="source-selector">
<a-segmented
v-model:value="uploadMode"
:options="uploadModeOptions"
size="large"
class="source-segmented"
:disabled="true"
/>
</div>
<div class="config-controls">
<a-button
type="dashed"
@click="showChunkConfigModal"
:disabled="isGraphBased"
>
<SettingOutlined /> 分块参数 ({{ chunkParams.chunk_size }}/{{ chunkParams.chunk_overlap }})
</a-button>
</div>
</div>
<div class="ocr-config" v-if="uploadMode === 'file'">
<a-form layout="horizontal">
<a-form-item label="使用OCR" name="enable_ocr">
<div class="ocr-controls">
<a-select
v-model:value="chunkParams.enable_ocr"
:options="enableOcrOptions"
style="width: 220px; margin-right: 12px;"
:disabled="ocrHealthChecking"
/>
<a-button
size="small"
type="dashed"
@click="checkOcrHealth"
:loading="ocrHealthChecking"
:icon="h(CheckCircleOutlined)"
>
检查OCR服务
</a-button>
</div>
<div class="param-description">
<div v-if="chunkParams.enable_ocr !== 'disable' && selectedOcrStatus && selectedOcrStatus !== 'healthy'" class="ocr-warning">
{{ selectedOcrMessage }}
</div>
<div v-else-if="chunkParams.enable_ocr !== 'disable' && selectedOcrStatus === 'healthy'" class="ocr-healthy">
{{ selectedOcrMessage }}
</div>
</div>
</a-form-item>
</a-form>
</div>
<!-- PDF/图片OCR提醒 -->
<div v-if="uploadMode === 'file' && hasPdfOrImageFiles && !isOcrEnabled" class="ocr-warning-alert">
检测到PDF或图片文件请启用OCR功能以提取文本内容
</div>
<!-- 文件上传区域 -->
<div class="upload" v-if="uploadMode === 'file'">
<a-upload-dragger
class="upload-dragger"
v-model:fileList="fileList"
name="file"
:multiple="true"
:disabled="chunkLoading"
:accept="acceptedFileTypes"
:before-upload="beforeUpload"
:action="'/api/knowledge/files/upload?db_id=' + databaseId"
:headers="getAuthHeaders()"
@change="handleFileUpload"
@drop="handleDrop"
>
<p class="ant-upload-text">点击或者把文件拖拽到这里上传</p>
<p class="ant-upload-hint">
支持的文件类型{{ uploadHint }}
</p>
<div class="zip-support-tip" v-if="hasZipFiles">
📦 zip 包会自动提取 Markdown 文件和图片图片链接将替换为可访问的 URL
</div>
</a-upload-dragger>
</div>
</div>
</a-modal>
<!-- 分块参数配置弹窗 -->
<a-modal v-model:open="chunkConfigModalVisible" title="分块参数配置" width="500px">
<template #footer>
<a-button key="back" @click="chunkConfigModalVisible = false">取消</a-button>
<a-button key="submit" type="primary" @click="handleChunkConfigSubmit">确定</a-button>
</template>
<div class="chunk-config-content">
<ChunkParamsConfig
:temp-chunk-params="tempChunkParams"
:show-qa-split="isQaSplitSupported"
/>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { message, Upload, Tooltip } from 'ant-design-vue';
import { useUserStore } from '@/stores/user';
import { useDatabaseStore } from '@/stores/database';
import { ocrApi } from '@/apis/system_api';
import { fileApi } from '@/apis/knowledge_api';
import ChunkParamsConfig from '@/components/ChunkParamsConfig.vue';
import {
FileOutlined,
LinkOutlined,
SettingOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons-vue';
import { h } from 'vue';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
});
const emit = defineEmits(['update:visible']);
const store = useDatabaseStore();
const DEFAULT_SUPPORTED_TYPES = [
'.txt',
'.pdf',
'.jpg',
'.jpeg',
'.md',
'.docx',
'.doc',
];
const normalizeExtensions = (extensions) => {
if (!Array.isArray(extensions)) {
return [];
}
const normalized = extensions
.map((ext) => (typeof ext === 'string' ? ext.trim().toLowerCase() : ''))
.filter((ext) => ext.length > 0)
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`));
return Array.from(new Set(normalized)).sort();
};
const supportedFileTypes = ref(normalizeExtensions(DEFAULT_SUPPORTED_TYPES));
const applySupportedFileTypes = (extensions) => {
const normalized = normalizeExtensions(extensions);
if (normalized.length > 0) {
supportedFileTypes.value = normalized;
} else {
supportedFileTypes.value = normalizeExtensions(DEFAULT_SUPPORTED_TYPES);
}
};
const acceptedFileTypes = computed(() => {
if (!supportedFileTypes.value.length) {
return '';
}
const exts = new Set(supportedFileTypes.value);
exts.add('.zip');
return Array.from(exts).join(',');
});
const uploadHint = computed(() => {
if (!supportedFileTypes.value.length) {
return '加载中...';
}
const exts = new Set(supportedFileTypes.value);
exts.add('.zip');
return Array.from(exts).join(', ');
});
const isSupportedExtension = (fileName) => {
if (!fileName) {
return true;
}
if (!supportedFileTypes.value.length) {
return true;
}
const lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex === -1) {
return false;
}
const ext = fileName.slice(lastDotIndex).toLowerCase();
return supportedFileTypes.value.includes(ext) || ext === '.zip';
};
const loadSupportedFileTypes = async () => {
try {
const data = await fileApi.getSupportedFileTypes();
applySupportedFileTypes(data?.file_types);
} catch (error) {
console.error('获取支持的文件类型失败:', error);
message.warning('获取支持的文件类型失败,已使用默认配置');
applySupportedFileTypes(DEFAULT_SUPPORTED_TYPES);
}
};
onMounted(() => {
loadSupportedFileTypes();
});
const visible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
});
const databaseId = computed(() => store.databaseId);
const kbType = computed(() => store.database.kb_type);
const chunkLoading = computed(() => store.state.chunkLoading);
// 上传模式
const uploadMode = ref('file');
const previousOcrSelection = ref('disable');
const uploadModeOptions = computed(() => [
{
value: 'file',
label: h('div', { class: 'segmented-option' }, [
h(FileOutlined, { class: 'option-icon' }),
h('span', { class: 'option-text' }, '上传文件'),
]),
},
{
value: 'url',
label: h(Tooltip, { title: 'URL 文档上传与解析功能已禁用,出于安全考虑,当前版本仅支持文件上传' }, {
default: () => h('div', { class: 'segmented-option' }, [
h(LinkOutlined, { class: 'option-icon' }),
h('span', { class: 'option-text' }, '输入网址'),
])
}),
},
]);
// 文件列表
const fileList = ref([]);
// URL相关功能已移除
// OCR服务健康状态
const ocrHealthStatus = ref({
onnx_rapid_ocr: { status: 'unknown', message: '' },
mineru_ocr: { status: 'unknown', message: '' },
mineru_official: { status: 'unknown', message: '' },
paddlex_ocr: { status: 'unknown', message: '' }
});
// OCR健康检查状态
const ocrHealthChecking = ref(false);
// 分块参数
const chunkParams = ref({
chunk_size: 1000,
chunk_overlap: 200,
enable_ocr: 'disable',
use_qa_split: false,
qa_separator: '\n\n\n',
});
// 分块参数配置弹窗
const chunkConfigModalVisible = ref(false);
// 临时分块参数(用于配置弹窗)
const tempChunkParams = ref({
chunk_size: 1000,
chunk_overlap: 200,
use_qa_split: false,
qa_separator: '\n\n\n',
});
// 计算属性是否支持QA分割
const isQaSplitSupported = computed(() => {
const type = kbType.value?.toLowerCase();
return type === 'chroma' || type === 'milvus';
});
const isGraphBased = computed(() => {
const type = kbType.value?.toLowerCase();
return type === 'lightrag';
});
// 计算属性是否启用了OCR
const isOcrEnabled = computed(() => {
return chunkParams.value.enable_ocr !== 'disable';
});
// 上传模式切换相关逻辑已移除
// 计算属性是否有PDF或图片文件
const hasPdfOrImageFiles = computed(() => {
if (fileList.value.length === 0) {
return false;
}
const pdfExtensions = ['.pdf'];
const imageExtensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.gif', '.webp'];
const ocrExtensions = [...pdfExtensions, ...imageExtensions];
return fileList.value.some(file => {
if (file.status !== 'done') {
return false;
}
const filePath = file.response?.file_path || file.name;
if (!filePath) {
return false;
}
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
return ocrExtensions.includes(ext);
});
});
// 计算属性是否有ZIP文件
const hasZipFiles = computed(() => {
if (fileList.value.length === 0) {
return false;
}
return fileList.value.some(file => {
if (file.status !== 'done') {
return false;
}
const filePath = file.response?.file_path || file.name;
if (!filePath) {
return false;
}
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
return ext === '.zip';
});
});
// 计算属性OCR选项
const enableOcrOptions = computed(() => [
{
value: 'disable',
label: '不启用',
title: '不启用'
},
{
value: 'onnx_rapid_ocr',
label: getRapidOcrLabel(),
title: 'ONNX with RapidOCR',
disabled: ocrHealthStatus.value?.onnx_rapid_ocr?.status === 'unavailable' || ocrHealthStatus.value?.onnx_rapid_ocr?.status === 'error'
},
{
value: 'mineru_ocr',
label: getMinerULabel(),
title: 'MinerU OCR',
disabled: ocrHealthStatus.value?.mineru_ocr?.status === 'unavailable' || ocrHealthStatus.value?.mineru_ocr?.status === 'error'
},
{
value: 'mineru_official',
label: getMinerUOfficialLabel(),
title: 'MinerU Official API',
disabled: ocrHealthStatus.value?.mineru_official?.status === 'unavailable' || ocrHealthStatus.value?.mineru_official?.status === 'error'
},
{
value: 'paddlex_ocr',
label: getPaddleXLabel(),
title: 'PaddleX OCR',
disabled: ocrHealthStatus.value?.paddlex_ocr?.status === 'unavailable' || ocrHealthStatus.value?.paddlex_ocr?.status === 'error'
},
]);
// 获取当前选中OCR服务的状态
const selectedOcrStatus = computed(() => {
switch (chunkParams.value.enable_ocr) {
case 'onnx_rapid_ocr':
return ocrHealthStatus.value?.onnx_rapid_ocr?.status || 'unknown';
case 'mineru_ocr':
return ocrHealthStatus.value?.mineru_ocr?.status || 'unknown';
case 'mineru_official':
return ocrHealthStatus.value?.mineru_official?.status || 'unknown';
case 'paddlex_ocr':
return ocrHealthStatus.value?.paddlex_ocr?.status || 'unknown';
default:
return null;
}
});
// 获取当前选中OCR服务的状态消息
const selectedOcrMessage = computed(() => {
switch (chunkParams.value.enable_ocr) {
case 'onnx_rapid_ocr':
return ocrHealthStatus.value?.onnx_rapid_ocr?.message || '';
case 'mineru_ocr':
return ocrHealthStatus.value?.mineru_ocr?.message || '';
case 'mineru_official':
return ocrHealthStatus.value?.mineru_official?.message || '';
case 'paddlex_ocr':
return ocrHealthStatus.value?.paddlex_ocr?.message || '';
default:
return '';
}
});
// OCR选项标签生成函数
const getRapidOcrLabel = () => {
const status = ocrHealthStatus.value?.onnx_rapid_ocr?.status || 'unknown';
const statusIcons = {
'healthy': '✅',
'unavailable': '❌',
'error': '⚠️',
'unknown': '❓'
};
return `${statusIcons[status] || '❓'} RapidOCR (ONNX)`;
};
const getMinerULabel = () => {
const status = ocrHealthStatus.value?.mineru_ocr?.status || 'unknown';
const statusIcons = {
'healthy': '✅',
'unavailable': '❌',
'unhealthy': '⚠️',
'timeout': '⏰',
'error': '⚠️',
'unknown': '❓'
};
return `${statusIcons[status] || '❓'} MinerU OCR`;
};
const getMinerUOfficialLabel = () => {
const status = ocrHealthStatus.value?.mineru_official?.status || 'unknown';
const statusIcons = {
'healthy': '✅',
'unavailable': '❌',
'unhealthy': '⚠️',
'timeout': '⏰',
'error': '⚠️',
'unknown': '❓'
};
return `${statusIcons[status] || '❓'} MinerU Official API`;
};
const getPaddleXLabel = () => {
const status = ocrHealthStatus.value?.paddlex_ocr?.status || 'unknown';
const statusIcons = {
'healthy': '✅',
'unavailable': '❌',
'unhealthy': '⚠️',
'timeout': '⏰',
'error': '⚠️',
'unknown': '❓'
};
return `${statusIcons[status] || '❓'} PaddleX OCR`;
};
// 验证OCR服务可用性
const validateOcrService = () => {
if (chunkParams.value.enable_ocr === 'disable') {
return true;
}
const status = selectedOcrStatus.value;
if (status === 'unavailable' || status === 'error') {
const ocrMessage = selectedOcrMessage.value;
message.error(`OCR服务不可用: ${ocrMessage}`);
return false;
}
return true;
};
const handleCancel = () => {
emit('update:visible', false);
};
const beforeUpload = (file) => {
if (!isSupportedExtension(file?.name)) {
message.error(`不支持的文件类型:${file?.name || '未知文件'}`);
return Upload.LIST_IGNORE;
}
return true;
};
const handleFileUpload = (info) => {
if (info?.file?.status === 'error') {
const errorMessage = info.file?.response?.detail || `文件上传失败:${info.file.name}`;
message.error(errorMessage);
}
fileList.value = info?.fileList ?? [];
};
const handleDrop = () => {};
// 已移除文件夹上传逻辑
const showChunkConfigModal = () => {
tempChunkParams.value = {
chunk_size: chunkParams.value.chunk_size,
chunk_overlap: chunkParams.value.chunk_overlap,
use_qa_split: isQaSplitSupported.value ? chunkParams.value.use_qa_split : false,
qa_separator: chunkParams.value.qa_separator,
};
chunkConfigModalVisible.value = true;
};
const handleChunkConfigSubmit = () => {
chunkParams.value.chunk_size = tempChunkParams.value.chunk_size;
chunkParams.value.chunk_overlap = tempChunkParams.value.chunk_overlap;
// 只有支持QA分割的知识库类型才保存QA分割配置
if (isQaSplitSupported.value) {
chunkParams.value.use_qa_split = tempChunkParams.value.use_qa_split;
chunkParams.value.qa_separator = tempChunkParams.value.qa_separator;
} else {
chunkParams.value.use_qa_split = false;
}
chunkConfigModalVisible.value = false;
message.success('分块参数配置已更新');
};
const checkOcrHealth = async () => {
if (ocrHealthChecking.value) return;
ocrHealthChecking.value = true;
try {
const healthData = await ocrApi.getHealth();
ocrHealthStatus.value = healthData.services;
} catch (error) {
console.error('OCR健康检查失败:', error);
message.error('OCR服务健康检查失败');
} finally {
ocrHealthChecking.value = false;
}
};
const getAuthHeaders = () => {
const userStore = useUserStore();
return userStore.getAuthHeaders();
};
const chunkData = async () => {
if (!databaseId.value) {
message.error('请先选择知识库');
return;
}
// 验证OCR服务可用性
if (!validateOcrService()) {
return;
}
let success = false;
if (uploadMode.value === 'file') {
const files = fileList.value.filter(file => file.status === 'done').map(file => file.response?.file_path);
// 过滤掉 undefined 或 null 的文件路径
const validFiles = files.filter(file => file);
if (validFiles.length === 0) {
message.error('请先上传文件');
return;
}
// 验证图片文件是否启用OCR
const imageExtensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'];
const hasImageFiles = validFiles.some(filePath => {
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
return imageExtensions.includes(ext);
});
if (hasImageFiles && chunkParams.value.enable_ocr === 'disable') {
message.error({
content: '检测到图片文件,必须启用 OCR 才能提取文本内容。请在上方选择 OCR 方式 (RapidOCR/MinerU/MinerU Official/PaddleX) 或移除图片文件。',
duration: 5,
});
return;
}
try {
store.state.chunkLoading = true;
success = await store.addFiles({ items: validFiles, contentType: 'file', params: chunkParams.value });
} catch (error) {
console.error('文件上传失败:', error);
message.error('文件上传失败: ' + (error.message || '未知错误'));
} finally {
store.state.chunkLoading = false;
}
}
if (success) {
emit('update:visible', false);
fileList.value = [];
}
};
</script>
<style lang="less" scoped>
.add-files-content {
padding: 16px 0;
display: flex;
flex-direction: column;
height: 100%;
.ant-form-item {
margin: 0;
}
}
.upload-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.source-selector {
display: flex;
align-items: center;
}
.config-controls {
display: flex;
align-items: center;
}
.source-segmented {
background-color: var(--gray-100);
border: 1px solid var(--gray-200);
}
.source-segmented :deep(.ant-segmented-item-label) {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: var(--gray-600);
}
.source-segmented :deep(.ant-segmented-item-selected .ant-segmented-item-label) {
color: var(--main-color);
}
.source-segmented :deep(.segmented-option) {
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.source-segmented :deep(.option-icon) {
font-size: 14px;
}
.ocr-config {
margin-bottom: 20px;
padding: 16px;
background-color: var(--gray-50);
border-radius: 6px;
}
.param-description {
font-size: 12px;
color: var(--gray-600);
margin-top: 4px;
}
.ocr-warning {
color: #faad14;
}
.ocr-healthy {
color: #52c41a;
}
.upload-dragger {
margin-bottom: 16px;
}
.url-hint {
font-size: 12px;
color: var(--gray-600);
margin-top: 8px;
}
.chunk-config-content .params-info {
margin-bottom: 16px;
}
// OCR警告提醒样式
.ocr-warning-alert {
margin: 12px 0;
padding: 8px 12px;
background: #fff7e6;
border: 1px solid #ffd666;
border-radius: 4px;
color: #d46b08;
font-size: 13px;
}
.folder-upload-tip {
margin-top: 12px;
padding: 12px;
background: #f0f7ff;
border-radius: 4px;
Add dark/light theme toggle feature (#343) * Add dark/light theme toggle feature * update: refine dark/light theme and related components * 调整语义化主题变量,优化暗/亮模式切换效果 * Revert "调整语义化主题变量,优化暗/亮模式切换效果" This reverts commit 85d9373297686fdbacb252a880b2847d20a39641. * 深色模式适配:减少 !important,迁移 CSS 变量,优化布局 * style: 替换硬编码颜色值为CSS变量以支持主题切换 将多处硬编码的颜色值(如#fff、#f0f0f0等)替换为CSS变量(如--gray-0、--gray-150等),统一管理颜色样式,便于主题切换和样式维护。主要修改包括背景色、边框色、文字色等视觉元素,同时移除不再需要的深色模式适配代码。 * style: 使用CSS变量替换硬编码颜色值 * refactor(theme): 重构主题系统,统一使用CSS变量并优化暗色模式实现 - 移除冗余的theme.js配置文件,将主题配置集中到theme store - 使用ant-design-vue的darkAlgorithm实现暗色模式 - 在多个组件中替换硬编码颜色值为CSS变量 - 优化用户信息组件,整合文档中心和主题切换功能 - 更新基础CSS样式,完善颜色系统和滚动条样式 * reset: 恢复被覆盖的修改(96d257a7ace38c94e2b113d56472d211d1141087) * feat(theme): 实现深色主题支持并重构样式系统 重构颜色变量系统,添加深色主题支持 移除硬编码颜色值,统一使用CSS变量 优化图表组件以响应主题切换 清理无用样式代码,提升可维护性 * docs: 更新开发规范文档 * style: 调整边框和背景颜色样式 --------- Co-authored-by: Wenjie Zhang <xerrors@163.com>
2025-11-23 01:39:44 +08:00
color: var(--gray-500);
font-size: 12px;
}
.zip-support-tip {
font-size: 12px;
color: var(--color-warning);
}
</style>