ai-robot-core/ai-service-admin/src/views/kb/index.vue

394 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<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>
</div>
<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">
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="name" label="文件名" min-width="200">
<template #default="scope">
<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">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<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">
<template #default="scope">
<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>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="jobDialogVisible" title="索引任务详情" width="500px" class="job-dialog">
<el-descriptions :column="1" border v-if="currentJob">
<el-descriptions-item label="任务ID">
<span class="job-id">{{ currentJob.jobId }}</span>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusType(currentJob.status)" size="small">
{{ getStatusText(currentJob.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="进度">
<div class="progress-wrapper">
<el-progress :percentage="currentJob.progress" :status="getProgressStatus(currentJob.status)" />
</div>
</el-descriptions-item>
<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>
<el-button
v-if="currentJob?.status === 'pending' || currentJob?.status === 'processing'"
type="primary"
@click="refreshJobStatus"
>
刷新状态
</el-button>
</template>
</el-dialog>
<input ref="fileInput" type="file" style="display: none" @change="handleFileChange" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, Document, View, Delete } from '@element-plus/icons-vue'
import { uploadDocument, listDocuments, getIndexJob, deleteDocument } from '@/api/kb'
interface DocumentItem {
docId: string
name: string
status: string
jobId: string
createTime: string
}
interface IndexJob {
jobId: string
status: string
progress: number
errorMsg?: string
}
const tableData = ref<DocumentItem[]>([])
const loading = ref(false)
const jobDialogVisible = ref(false)
const currentJob = ref<IndexJob | null>(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
}
const getProgressStatus = (status: string) => {
if (status === 'completed') return 'success'
if (status === 'failed') return 'exception'
return undefined
}
const fetchDocuments = async () => {
try {
const res = await listDocuments({})
tableData.value = res.data.map((doc: any) => ({
docId: doc.docId,
name: doc.fileName,
status: doc.status,
jobId: doc.jobId,
createTime: new Date(doc.createdAt).toLocaleString('zh-CN')
}))
} catch (error) {
console.error('Failed to fetch documents:', error)
}
}
const fetchJobStatus = async (jobId: string): Promise<IndexJob | null> => {
try {
const res: any = await getIndexJob(jobId)
return {
jobId: res.jobId || jobId,
status: res.status || 'pending',
progress: res.progress || 0,
errorMsg: res.errorMsg
}
} 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
}
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 || '删除失败')
}
}
}
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')
try {
loading.value = true
const res: any = await uploadDocument(formData)
const jobId = res.jobId as string
ElMessage.success(`文档上传成功任务ID: ${jobId}`)
console.log('Upload response:', res)
const newDoc: DocumentItem = {
docId: res.docId || '',
name: file.name,
status: (res.status as string) || 'pending',
jobId: jobId,
createTime: new Date().toLocaleString('zh-CN')
}
tableData.value.unshift(newDoc)
startPolling(jobId)
} catch (error) {
ElMessage.error('文档上传失败')
console.error('Upload error:', error)
} finally {
loading.value = false
target.value = ''
}
}
</script>
<style scoped>
.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%;
}
}
</style>