2026-02-24 06:54:14 +00:00
|
|
|
|
<template>
|
2026-02-25 06:06:37 +00:00
|
|
|
|
<div class="kb-page">
|
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
|
<div class="header-content">
|
|
|
|
|
|
<div class="title-section">
|
|
|
|
|
|
<h1 class="page-title">知识库管理</h1>
|
|
|
|
|
|
<p class="page-desc">上传文档并建立向量索引,支持多种文档格式。</p>
|
2026-02-24 06:54:14 +00:00
|
|
|
|
</div>
|
2026-02-25 06:06:37 +00:00
|
|
|
|
<div class="header-actions">
|
|
|
|
|
|
<el-button type="primary" @click="handleUploadClick">
|
|
|
|
|
|
<el-icon><Upload /></el-icon>
|
|
|
|
|
|
上传文档
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<el-card shadow="hover" class="table-card">
|
2026-02-24 10:18:43 +00:00
|
|
|
|
<el-table v-loading="loading" :data="tableData" style="width: 100%">
|
2026-02-25 06:06:37 +00:00
|
|
|
|
<el-table-column prop="name" label="文件名" min-width="200">
|
2026-02-24 06:54:14 +00:00
|
|
|
|
<template #default="scope">
|
2026-02-25 06:06:37 +00:00
|
|
|
|
<div class="file-name">
|
|
|
|
|
|
<el-icon class="file-icon"><Document /></el-icon>
|
|
|
|
|
|
<span>{{ scope.row.name }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="status" label="状态" width="120">
|
|
|
|
|
|
<template #default="scope">
|
|
|
|
|
|
<el-tag :type="getStatusType(scope.row.status)" size="small">
|
2026-02-24 10:18:43 +00:00
|
|
|
|
{{ getStatusText(scope.row.status) }}
|
2026-02-24 06:54:14 +00:00
|
|
|
|
</el-tag>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
2026-02-25 06:06:37 +00:00
|
|
|
|
<el-table-column prop="jobId" label="任务ID" width="180">
|
|
|
|
|
|
<template #default="scope">
|
|
|
|
|
|
<span class="job-id">{{ scope.row.jobId || '-' }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="createTime" label="上传时间" width="180" />
|
|
|
|
|
|
<el-table-column label="操作" width="160" fixed="right">
|
2026-02-24 10:18:43 +00:00
|
|
|
|
<template #default="scope">
|
2026-02-25 06:06:37 +00:00
|
|
|
|
<el-button link type="primary" @click="handleViewJob(scope.row)">
|
|
|
|
|
|
<el-icon><View /></el-icon>
|
|
|
|
|
|
详情
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<el-button link type="danger" @click="handleDelete(scope.row)">
|
|
|
|
|
|
<el-icon><Delete /></el-icon>
|
|
|
|
|
|
删除
|
|
|
|
|
|
</el-button>
|
2026-02-24 06:54:14 +00:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
|
|
|
|
|
</el-card>
|
2026-02-24 10:18:43 +00:00
|
|
|
|
|
2026-02-25 06:06:37 +00:00
|
|
|
|
<el-dialog v-model="jobDialogVisible" title="索引任务详情" width="500px" class="job-dialog">
|
2026-02-24 10:18:43 +00:00
|
|
|
|
<el-descriptions :column="1" border v-if="currentJob">
|
2026-02-25 06:06:37 +00:00
|
|
|
|
<el-descriptions-item label="任务ID">
|
|
|
|
|
|
<span class="job-id">{{ currentJob.jobId }}</span>
|
|
|
|
|
|
</el-descriptions-item>
|
2026-02-24 10:18:43 +00:00
|
|
|
|
<el-descriptions-item label="状态">
|
2026-02-25 06:06:37 +00:00
|
|
|
|
<el-tag :type="getStatusType(currentJob.status)" size="small">
|
2026-02-24 10:18:43 +00:00
|
|
|
|
{{ getStatusText(currentJob.status) }}
|
|
|
|
|
|
</el-tag>
|
|
|
|
|
|
</el-descriptions-item>
|
2026-02-25 06:06:37 +00:00
|
|
|
|
<el-descriptions-item label="进度">
|
|
|
|
|
|
<div class="progress-wrapper">
|
|
|
|
|
|
<el-progress :percentage="currentJob.progress" :status="getProgressStatus(currentJob.status)" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-descriptions-item>
|
2026-02-24 10:18:43 +00:00
|
|
|
|
<el-descriptions-item label="错误信息" v-if="currentJob.errorMsg">
|
|
|
|
|
|
<el-alert type="error" :closable="false">{{ currentJob.errorMsg }}</el-alert>
|
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
|
</el-descriptions>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button @click="jobDialogVisible = false">关闭</el-button>
|
2026-02-25 06:06:37 +00:00
|
|
|
|
<el-button
|
|
|
|
|
|
v-if="currentJob?.status === 'pending' || currentJob?.status === 'processing'"
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
@click="refreshJobStatus"
|
|
|
|
|
|
>
|
2026-02-24 10:18:43 +00:00
|
|
|
|
刷新状态
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
|
|
|
|
|
|
<input ref="fileInput" type="file" style="display: none" @change="handleFileChange" />
|
2026-02-24 06:54:14 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-02-24 10:18:43 +00:00
|
|
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
2026-02-24 11:52:52 +00:00
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
2026-02-25 06:06:37 +00:00
|
|
|
|
import { Upload, Document, View, Delete } from '@element-plus/icons-vue'
|
2026-02-24 11:52:52 +00:00
|
|
|
|
import { uploadDocument, listDocuments, getIndexJob, deleteDocument } from '@/api/kb'
|
2026-02-24 10:18:43 +00:00
|
|
|
|
|
|
|
|
|
|
interface DocumentItem {
|
2026-02-24 11:52:52 +00:00
|
|
|
|
docId: string
|
2026-02-24 10:18:43 +00:00
|
|
|
|
name: string
|
|
|
|
|
|
status: string
|
|
|
|
|
|
jobId: string
|
|
|
|
|
|
createTime: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tableData = ref<DocumentItem[]>([])
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const jobDialogVisible = ref(false)
|
|
|
|
|
|
const currentJob = ref<any>(null)
|
|
|
|
|
|
const pollingJobs = ref<Set<string>>(new Set())
|
|
|
|
|
|
let pollingInterval: number | null = null
|
|
|
|
|
|
|
|
|
|
|
|
const getStatusType = (status: string) => {
|
|
|
|
|
|
const typeMap: Record<string, string> = {
|
|
|
|
|
|
completed: 'success',
|
|
|
|
|
|
processing: 'warning',
|
|
|
|
|
|
pending: 'info',
|
|
|
|
|
|
failed: 'danger'
|
|
|
|
|
|
}
|
|
|
|
|
|
return typeMap[status] || 'info'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const getStatusText = (status: string) => {
|
|
|
|
|
|
const textMap: Record<string, string> = {
|
|
|
|
|
|
completed: '已完成',
|
|
|
|
|
|
processing: '处理中',
|
|
|
|
|
|
pending: '等待中',
|
|
|
|
|
|
failed: '失败'
|
|
|
|
|
|
}
|
|
|
|
|
|
return textMap[status] || status
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 06:06:37 +00:00
|
|
|
|
const getProgressStatus = (status: string) => {
|
|
|
|
|
|
if (status === 'completed') return 'success'
|
|
|
|
|
|
if (status === 'failed') return 'exception'
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 10:18:43 +00:00
|
|
|
|
const fetchDocuments = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await listDocuments({})
|
|
|
|
|
|
tableData.value = res.data.map((doc: any) => ({
|
2026-02-24 11:52:52 +00:00
|
|
|
|
docId: doc.docId,
|
2026-02-24 10:18:43 +00:00
|
|
|
|
name: doc.fileName,
|
|
|
|
|
|
status: doc.status,
|
2026-02-24 11:52:52 +00:00
|
|
|
|
jobId: doc.jobId,
|
2026-02-24 10:18:43 +00:00
|
|
|
|
createTime: new Date(doc.createdAt).toLocaleString('zh-CN')
|
|
|
|
|
|
}))
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to fetch documents:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fetchJobStatus = async (jobId: string) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getIndexJob(jobId)
|
|
|
|
|
|
return res
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to fetch job status:', error)
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleViewJob = async (row: DocumentItem) => {
|
|
|
|
|
|
if (!row.jobId) {
|
|
|
|
|
|
ElMessage.warning('该文档没有任务ID')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
currentJob.value = await fetchJobStatus(row.jobId)
|
|
|
|
|
|
jobDialogVisible.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 11:52:52 +00:00
|
|
|
|
const handleDelete = async (row: DocumentItem) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ElMessageBox.confirm(`确定要删除文档 "${row.name}" 吗?`, '确认删除', {
|
|
|
|
|
|
confirmButtonText: '确定',
|
|
|
|
|
|
cancelButtonText: '取消',
|
|
|
|
|
|
type: 'warning'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await deleteDocument(row.docId)
|
|
|
|
|
|
ElMessage.success('文档删除成功')
|
|
|
|
|
|
fetchDocuments()
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
if (error !== 'cancel') {
|
|
|
|
|
|
ElMessage.error(error.message || '删除失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 10:18:43 +00:00
|
|
|
|
const refreshJobStatus = async () => {
|
|
|
|
|
|
if (currentJob.value?.jobId) {
|
|
|
|
|
|
currentJob.value = await fetchJobStatus(currentJob.value.jobId)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const startPolling = (jobId: string) => {
|
|
|
|
|
|
pollingJobs.value.add(jobId)
|
|
|
|
|
|
if (!pollingInterval) {
|
|
|
|
|
|
pollingInterval = window.setInterval(async () => {
|
|
|
|
|
|
for (const jobId of pollingJobs.value) {
|
|
|
|
|
|
const job = await fetchJobStatus(jobId)
|
|
|
|
|
|
if (job) {
|
|
|
|
|
|
if (job.status === 'completed') {
|
|
|
|
|
|
pollingJobs.value.delete(jobId)
|
|
|
|
|
|
ElMessage.success('文档索引任务已完成')
|
|
|
|
|
|
fetchDocuments()
|
|
|
|
|
|
} else if (job.status === 'failed') {
|
|
|
|
|
|
pollingJobs.value.delete(jobId)
|
|
|
|
|
|
ElMessage.error('文档索引任务失败')
|
|
|
|
|
|
ElMessage.warning(`错误: ${job.errorMsg}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pollingJobs.value.size === 0 && pollingInterval) {
|
|
|
|
|
|
clearInterval(pollingInterval)
|
|
|
|
|
|
pollingInterval = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 3000)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
fetchDocuments()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
if (pollingInterval) {
|
|
|
|
|
|
clearInterval(pollingInterval)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
const handleUploadClick = () => {
|
|
|
|
|
|
fileInput.value?.click()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleFileChange = async (event: Event) => {
|
|
|
|
|
|
const target = event.target as HTMLInputElement
|
|
|
|
|
|
const file = target.files?.[0]
|
|
|
|
|
|
if (!file) return
|
|
|
|
|
|
|
|
|
|
|
|
const formData = new FormData()
|
|
|
|
|
|
formData.append('file', file)
|
|
|
|
|
|
formData.append('kb_id', 'kb_default')
|
2026-02-24 06:54:14 +00:00
|
|
|
|
|
2026-02-24 10:18:43 +00:00
|
|
|
|
try {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
const res = await uploadDocument(formData)
|
|
|
|
|
|
ElMessage.success(`文档上传成功!任务ID: ${res.jobId}`)
|
|
|
|
|
|
console.log('Upload response:', res)
|
|
|
|
|
|
|
|
|
|
|
|
const newDoc: DocumentItem = {
|
|
|
|
|
|
name: file.name,
|
|
|
|
|
|
status: res.status || 'pending',
|
|
|
|
|
|
jobId: res.jobId,
|
|
|
|
|
|
createTime: new Date().toLocaleString('zh-CN')
|
|
|
|
|
|
}
|
|
|
|
|
|
tableData.value.unshift(newDoc)
|
|
|
|
|
|
|
|
|
|
|
|
startPolling(res.jobId)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ElMessage.error('文档上传失败')
|
|
|
|
|
|
console.error('Upload error:', error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
target.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-24 06:54:14 +00:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-02-25 06:06:37 +00:00
|
|
|
|
.kb-page {
|
|
|
|
|
|
padding: 24px;
|
|
|
|
|
|
min-height: calc(100vh - 60px);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.page-header {
|
|
|
|
|
|
margin-bottom: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-content {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.title-section {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 200px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.page-title {
|
|
|
|
|
|
margin: 0 0 8px 0;
|
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
|
letter-spacing: -0.5px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.page-desc {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.table-card {
|
|
|
|
|
|
animation: fadeInUp 0.5s ease-out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes fadeInUp {
|
|
|
|
|
|
from {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: translateY(20px);
|
|
|
|
|
|
}
|
|
|
|
|
|
to {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
transform: translateY(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.file-name {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.file-icon {
|
|
|
|
|
|
color: var(--primary-color);
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.job-id {
|
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
|
background-color: var(--bg-tertiary);
|
|
|
|
|
|
padding: 2px 8px;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.progress-wrapper {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.job-dialog :deep(.el-dialog__header) {
|
|
|
|
|
|
border-bottom: 1px solid var(--border-light);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.job-dialog :deep(.el-dialog__body) {
|
|
|
|
|
|
padding: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.kb-page {
|
|
|
|
|
|
padding: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.page-title {
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-content {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.title-section {
|
|
|
|
|
|
min-width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-24 06:54:14 +00:00
|
|
|
|
</style>
|