457 lines
12 KiB
Vue
457 lines
12 KiB
Vue
<template>
|
||
<div class="knowledge-ingest">
|
||
<!-- 第一步:上传区域 -->
|
||
<div class="section upload-section">
|
||
<h3>步骤1: 上传文件夹到MinIO</h3>
|
||
|
||
<!-- 文件夹选择 -->
|
||
<input
|
||
type="file"
|
||
ref="folderInput"
|
||
style="display: none"
|
||
webkitdirectory
|
||
multiple
|
||
@change="handleFolderSelect"
|
||
:disabled="processCompleted"
|
||
/>
|
||
<el-button
|
||
type="primary"
|
||
@click="$refs.folderInput.click()"
|
||
:disabled="processCompleted"
|
||
>
|
||
选择文件夹
|
||
</el-button>
|
||
|
||
<!-- 文件列表预览 -->
|
||
<div v-if="selectedFiles.length > 0" class="file-list">
|
||
<h4>待上传文件(共 {{ selectedFiles.length }} 个)</h4>
|
||
<el-tree
|
||
:data="fileTree"
|
||
:props="{ label: 'name', children: 'children' }"
|
||
default-expand-all
|
||
class="tree-view"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 上传按钮 -->
|
||
<el-button
|
||
type="success"
|
||
:loading="uploading"
|
||
@click="uploadFolder"
|
||
:disabled="selectedFiles.length === 0 || uploading || processCompleted"
|
||
class="action-btn"
|
||
>
|
||
{{ uploading ? '上传中...' : '开始上传' }}
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 第二步:RAG入库区域 -->
|
||
<div v-if="showIngestSection" class="section ingest-section">
|
||
<el-divider />
|
||
<h3>步骤2: RAG知识库入库</h3>
|
||
|
||
<el-alert title="上传成功!" type="success" :closable="false" show-icon>
|
||
<div class="upload-info">
|
||
<span>
|
||
文件夹ID:
|
||
<strong>{{ uploadResult.folderId }}</strong>
|
||
</span>
|
||
<span>
|
||
文件数量:
|
||
<strong>{{ uploadResult.fileCount }}</strong>
|
||
</span>
|
||
<span>
|
||
存储桶:
|
||
<strong>{{ uploadResult.bucketName }}</strong>
|
||
</span>
|
||
</div>
|
||
</el-alert>
|
||
|
||
<el-button
|
||
type="warning"
|
||
:loading="ingesting"
|
||
@click="startRagIngest"
|
||
:disabled="ingesting || ingestCompleted || processCompleted"
|
||
class="action-btn"
|
||
>
|
||
{{
|
||
ingesting ? '入库中...' : ingestCompleted ? '已完成' : '开始RAG入库'
|
||
}}
|
||
</el-button>
|
||
|
||
<!-- ✅ 进度条显示条件:进行中 或 已完成 -->
|
||
<div v-if="ingesting || ingestCompleted" class="progress-area">
|
||
<h4>RAG入库进度</h4>
|
||
<el-progress
|
||
:percentage="ingestProgress"
|
||
:status="ingestStatus"
|
||
:stroke-width="20"
|
||
:text-inside="true"
|
||
/>
|
||
<p v-if="currentFile" class="current-file">
|
||
正在处理: {{ currentFile }}
|
||
</p>
|
||
|
||
<!-- ✅ 完成提示 -->
|
||
<div v-if="ingestCompleted" class="completion-status">
|
||
<el-icon class="success-icon" :size="48" color="#67C23A">
|
||
<CircleCheckFilled />
|
||
</el-icon>
|
||
<p class="complete-text">✅ RAG入库已完成!</p>
|
||
<p class="complete-detail">
|
||
共处理 {{ uploadResult.fileCount }} 个文件
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ✅ 修改后的刷新按钮区域 -->
|
||
<div v-if="processCompleted" class="refresh-section">
|
||
<el-divider />
|
||
<el-button
|
||
type="primary"
|
||
size="large"
|
||
@click="resetAllData"
|
||
class="refresh-btn"
|
||
>
|
||
<el-icon><RefreshRight /></el-icon>
|
||
开始新流程
|
||
</el-button>
|
||
<p class="refresh-hint">点击按钮将重置所有数据,开始新的上传入库流程</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import { ElMessage, ElIcon } from 'element-plus'
|
||
import { CircleCheckFilled, RefreshRight } from '@element-plus/icons-vue'
|
||
import {
|
||
submitRagIngestTask,
|
||
getRagIngestProgress,
|
||
uploadFolder,
|
||
} from '@/api/rag'
|
||
import { getUserRoles } from '@/api/sysFRole'
|
||
import { GetUserinfo } from '@/api/login'
|
||
|
||
export default {
|
||
components: {
|
||
ElIcon,
|
||
CircleCheckFilled,
|
||
RefreshRight,
|
||
},
|
||
|
||
data() {
|
||
return {
|
||
selectedFiles: [],
|
||
fileTree: [],
|
||
uploading: false,
|
||
uploadResult: null,
|
||
ingesting: false,
|
||
ingestProgress: 0,
|
||
ingestStatus: '',
|
||
currentFile: '',
|
||
taskId: null,
|
||
ingestCompleted: false,
|
||
processCompleted: false, // ✅ 新增:标记整个流程是否结束
|
||
}
|
||
},
|
||
|
||
computed: {
|
||
showIngestSection() {
|
||
// ✅ 流程结束后仍然显示,只是按钮禁用
|
||
return Boolean(this.uploadResult?.folderId) && !this.uploading
|
||
},
|
||
},
|
||
|
||
methods: {
|
||
handleFolderSelect(event) {
|
||
// ✅ 流程结束后禁用选择
|
||
if (this.processCompleted) return
|
||
|
||
const files = Array.from(event.target.files)
|
||
if (files.length === 0) return
|
||
|
||
// ✅ 检查文件大小(例如:限制单个文件 100MB)
|
||
const MAX_FILE_SIZE = 200 * 1024 * 1024 * 1024 // 100MB
|
||
const oversizedFiles = files.filter(f => f.size > MAX_FILE_SIZE)
|
||
|
||
if (oversizedFiles.length > 0) {
|
||
// 显示错误提示(自动换行)
|
||
ElMessage.error({
|
||
message: `以下文件超过 2GB 限制:\n${oversizedFiles
|
||
.map(f => f.name)
|
||
.join('\n')}`,
|
||
duration: 5000, // 显示5秒
|
||
showClose: true,
|
||
dangerouslyUseHTMLString: true, // 支持HTML(可选)
|
||
})
|
||
|
||
// 清空文件输入框和已选文件
|
||
this.$refs.folderInput.value = null
|
||
this.selectedFiles = []
|
||
this.fileTree = []
|
||
return // 中断后续操作
|
||
}
|
||
|
||
this.selectedFiles = files.map(file => ({
|
||
file: file,
|
||
relativePath: file.webkitRelativePath,
|
||
size: file.size,
|
||
}))
|
||
this.buildFileTree()
|
||
},
|
||
|
||
buildFileTree() {
|
||
const tree = { name: '根目录', children: {} }
|
||
this.selectedFiles.forEach(({ relativePath }) => {
|
||
const parts = relativePath.split('/').filter(p => p)
|
||
let current = tree.children
|
||
parts.forEach(part => {
|
||
if (!current[part]) {
|
||
current[part] = { name: part, children: {} }
|
||
}
|
||
current = current[part].children
|
||
})
|
||
})
|
||
this.fileTree = this.convertToTreeData(tree.children)
|
||
},
|
||
|
||
convertToTreeData(children) {
|
||
return Object.keys(children).map(key => {
|
||
const node = children[key]
|
||
return {
|
||
name: node.name,
|
||
children:
|
||
Object.keys(node.children).length > 0
|
||
? this.convertToTreeData(node.children)
|
||
: undefined,
|
||
}
|
||
})
|
||
},
|
||
|
||
async uploadFolder() {
|
||
// ✅ 流程结束后禁用上传
|
||
if (this.processCompleted) return
|
||
|
||
this.uploading = true
|
||
this.uploadResult = null
|
||
|
||
const formData = new FormData()
|
||
this.selectedFiles.forEach(({ file, relativePath }) => {
|
||
formData.append('files', file)
|
||
formData.append('relativePaths', relativePath)
|
||
})
|
||
|
||
const folderName = this.$refs.folderInput.files[0].webkitRelativePath.split(
|
||
'/'
|
||
)[0]
|
||
formData.append('sender', 'user')
|
||
formData.append('folderName', folderName)
|
||
|
||
try {
|
||
const res = await uploadFolder(formData)
|
||
if (res.code === 200 && res.data) {
|
||
this.uploadResult = res.data
|
||
ElMessage.success(`上传成功!共 ${res.data.fileCount} 个文件`)
|
||
} else {
|
||
throw new Error(res.message || `上传失败 (code: ${res.code})`)
|
||
}
|
||
} catch (error) {
|
||
const msg = error.response?.data?.message || error.message || '上传失败'
|
||
ElMessage.error(msg)
|
||
} finally {
|
||
this.uploading = false
|
||
}
|
||
},
|
||
|
||
async startRagIngest() {
|
||
// ✅ 流程结束后禁用入库
|
||
if (this.processCompleted) return
|
||
|
||
if (!this.uploadResult?.folderId) {
|
||
ElMessage.warning('请先完成文件上传')
|
||
return
|
||
}
|
||
|
||
this.ingesting = true
|
||
this.ingestCompleted = false
|
||
this.ingestProgress = 0
|
||
this.ingestStatus = ''
|
||
this.currentFile = ''
|
||
|
||
try {
|
||
const userInfoRes = await GetUserinfo()
|
||
const userId = userInfoRes.data.id
|
||
|
||
const roleRes = await getUserRoles(userId)
|
||
const userRoles = roleRes.data || []
|
||
|
||
const userRoleCode = userRoles.some(r => r?.roleCode === 'admin')
|
||
? 'admin'
|
||
: userRoles[0]?.roleCode || 'default'
|
||
|
||
const res = await submitRagIngestTask(
|
||
this.uploadResult.folderId,
|
||
userRoleCode
|
||
)
|
||
|
||
if (res.code === 200 && res.data) {
|
||
this.taskId = res.data.taskId
|
||
ElMessage.success(`入库任务已提交,角色: ${userRoleCode}`)
|
||
this.pollIngestProgress(this.taskId, userRoleCode)
|
||
} else {
|
||
throw new Error(res.message || '提交失败')
|
||
}
|
||
} catch (error) {
|
||
const msg = error.response?.data?.message || error.message || '入库失败'
|
||
ElMessage.error(msg)
|
||
this.ingesting = false
|
||
}
|
||
},
|
||
|
||
pollIngestProgress(taskId, userRoleCode) {
|
||
let retryCount = 0
|
||
const maxRetries = 10
|
||
|
||
const interval = setInterval(async () => {
|
||
try {
|
||
const res = await getRagIngestProgress(taskId, userRoleCode)
|
||
|
||
const { code, data } = res
|
||
if (code === 200 && data) {
|
||
this.ingestProgress = data.progress || 0
|
||
this.currentFile = data.currentFile || ''
|
||
|
||
switch (data.status) {
|
||
case 'completed':
|
||
this.ingestStatus = 'success'
|
||
clearInterval(interval)
|
||
this.ingesting = false
|
||
this.ingestCompleted = true
|
||
this.processCompleted = true // ✅ 标记整个流程结束
|
||
ElMessage.success('RAG入库完成!')
|
||
// ✅ 可以添加一个提示
|
||
ElMessage.info('请点击"开始新流程"按钮进行下一次操作')
|
||
break
|
||
case 'failed':
|
||
this.ingestStatus = 'exception'
|
||
clearInterval(interval)
|
||
this.ingesting = false
|
||
ElMessage.error(`入库失败: ${data.errorMessage}`)
|
||
break
|
||
default:
|
||
retryCount = 0
|
||
}
|
||
} else {
|
||
throw new Error('查询进度失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('轮询错误:', error)
|
||
if (retryCount++ >= maxRetries) {
|
||
clearInterval(interval)
|
||
this.ingesting = false
|
||
ElMessage.error('进度查询失败')
|
||
}
|
||
}
|
||
}, 1500)
|
||
},
|
||
|
||
// ✅ 新增:刷新页面方法
|
||
refreshPage() {
|
||
// 方式1: 使用 Vue Router (推荐)
|
||
this.$router.go(0)
|
||
|
||
// 方式2: 使用 location.reload()
|
||
// location.reload()
|
||
|
||
// 方式3: 重置所有数据(不刷新页面)
|
||
// this.resetAllData()
|
||
},
|
||
|
||
// ✅ 修改:重置所有数据而不刷新页面
|
||
resetAllData() {
|
||
// 重置所有数据
|
||
this.selectedFiles = []
|
||
this.fileTree = []
|
||
this.uploading = false
|
||
this.uploadResult = null
|
||
this.ingesting = false
|
||
this.ingestProgress = 0
|
||
this.ingestStatus = ''
|
||
this.currentFile = ''
|
||
this.taskId = null
|
||
this.ingestCompleted = false
|
||
this.processCompleted = false
|
||
|
||
// 清空文件输入框
|
||
if (this.$refs.folderInput) {
|
||
this.$refs.folderInput.value = null
|
||
}
|
||
|
||
// 显示成功提示
|
||
ElMessage.success('页面已重置,可以开始新流程')
|
||
},
|
||
},
|
||
}
|
||
</script>
|
||
|
||
<style lang="css" scoped>
|
||
@import 'knowledge-ingest.css';
|
||
|
||
/* ✅ 新增:完成状态样式 */
|
||
.complete-text {
|
||
margin-top: 10px;
|
||
font-size: 16px;
|
||
color: #67c23a;
|
||
font-weight: bold;
|
||
text-align: center;
|
||
}
|
||
|
||
.complete-detail {
|
||
font-size: 14px;
|
||
color: #909399;
|
||
text-align: center;
|
||
margin-top: 5px;
|
||
}
|
||
|
||
.completion-status {
|
||
margin-top: 20px;
|
||
text-align: center;
|
||
padding: 20px;
|
||
background: #f0f9ff;
|
||
border-radius: 8px;
|
||
border: 1px solid #b3e19d;
|
||
}
|
||
|
||
.success-icon {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
/* ✅ 新增:刷新按钮区域样式 */
|
||
.refresh-section {
|
||
margin-top: 30px;
|
||
text-align: center;
|
||
padding: 20px;
|
||
background: #fef0f0;
|
||
border-radius: 8px;
|
||
border: 1px solid #fbc4c4;
|
||
}
|
||
|
||
.refresh-btn {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.refresh-hint {
|
||
color: #f56c6c;
|
||
font-size: 12px;
|
||
margin: 5px 0 0 0;
|
||
}
|
||
|
||
/* 禁用状态按钮 */
|
||
.action-btn:disabled,
|
||
input[type='file']:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.6;
|
||
}
|
||
</style>
|