feat: 前端页面优化与入库任务重试/删除
- 概览与检索合并为单一概览页 - 类目/文档管理/入库整合为树形目录知识库页(Library),支持文档搜索 - 入库进度改为右侧 Drawer,支持任务详情、重试、删除 - 后端新增任务重试与删除接口
This commit is contained in:
@@ -0,0 +1,815 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
FolderOutlined,
|
||||
FileTextOutlined,
|
||||
UploadOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
ClockCircleOutlined,
|
||||
TagsOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
categories as fetchCategories,
|
||||
stats as fetchStats
|
||||
} from '@/api/knowledge'
|
||||
import {
|
||||
list as fetchDocuments,
|
||||
detail as fetchDocumentDetail,
|
||||
remove as deleteDocument,
|
||||
reingest as reingestDocument,
|
||||
ingest as ingestDocument,
|
||||
upload as uploadDocument,
|
||||
uploadBatch
|
||||
} from '@/api/documents'
|
||||
import { useIngestPolling } from '@/composables/useIngestPolling'
|
||||
import { useTasksDrawer } from '@/composables/useTasksDrawer'
|
||||
import { INGEST_STATUS_TEXT, INGEST_STATUS_COLOR } from '@/constants/ingest'
|
||||
|
||||
const tasksDrawer = useTasksDrawer()
|
||||
|
||||
/* ---------- 树形目录 ---------- */
|
||||
const isLoading = ref(false)
|
||||
const treeData = ref([])
|
||||
const categoriesData = ref([])
|
||||
const categoryDesc = ref(null)
|
||||
const selectedCategory = ref(null)
|
||||
|
||||
const allDocs = ref([])
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const UNCATEGORIZED = 'uncategorized'
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes == null) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function buildTree() {
|
||||
const docs = allDocs.value
|
||||
const filtered = searchKeyword.value
|
||||
? docs.filter((d) => {
|
||||
const kw = searchKeyword.value.toLowerCase()
|
||||
return (
|
||||
(d.title || '').toLowerCase().includes(kw) ||
|
||||
(d.summary || '').toLowerCase().includes(kw) ||
|
||||
(d.category || '').toLowerCase().includes(kw)
|
||||
)
|
||||
})
|
||||
: docs
|
||||
|
||||
// 类目 → 文档映射
|
||||
const byCat = {}
|
||||
filtered.forEach((d) => {
|
||||
const cat = d.category || UNCATEGORIZED
|
||||
if (!byCat[cat]) byCat[cat] = []
|
||||
byCat[cat].push(d)
|
||||
})
|
||||
|
||||
const nodes = []
|
||||
// 定义类目顺序:taxonomy 中定义的类目在前,未定义但存在的类目追加
|
||||
const defined = categoriesData.value.map((c) => c.name)
|
||||
const known = new Set(defined)
|
||||
Object.keys(byCat).forEach((cat) => {
|
||||
if (!known.has(cat)) known.add(cat)
|
||||
})
|
||||
defined.forEach((cat) => {
|
||||
if (byCat[cat]) {
|
||||
nodes.push(makeCategoryNode(cat, byCat[cat]))
|
||||
}
|
||||
})
|
||||
// 未在 taxonomy 中但实际存在的类目
|
||||
Object.keys(byCat).forEach((cat) => {
|
||||
if (!defined.includes(cat)) {
|
||||
nodes.push(makeCategoryNode(cat, byCat[cat]))
|
||||
}
|
||||
})
|
||||
return nodes
|
||||
}
|
||||
|
||||
function makeCategoryNode(cat, docs) {
|
||||
return {
|
||||
key: `cat:${cat}`,
|
||||
title: cat,
|
||||
icon: () => h(FolderOutlined),
|
||||
isLeaf: docs.length === 0,
|
||||
category: cat,
|
||||
count: docs.length,
|
||||
children: docs.map((d) => ({
|
||||
key: `doc:${d.doc_id}`,
|
||||
title: d.title || '(无标题)',
|
||||
icon: () => h(FileTextOutlined),
|
||||
isLeaf: true,
|
||||
doc: d
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function h(type) {
|
||||
return { render: () => _h(type) }
|
||||
}
|
||||
|
||||
function _h(type) {
|
||||
return type
|
||||
}
|
||||
|
||||
const treeDataComputed = computed(() => buildTree())
|
||||
|
||||
function handleTreeSelect(keys, info) {
|
||||
const node = info.selectedNodes?.[0]
|
||||
if (!node) return
|
||||
const key = node.key
|
||||
if (key.startsWith('cat:')) {
|
||||
selectedCategory.value = node.category
|
||||
categoryDesc.value = categoriesData.value.find((c) => c.name === node.category)?.description || ''
|
||||
closeDocDetail()
|
||||
} else if (key.startsWith('doc:')) {
|
||||
selectedCategory.value = null
|
||||
categoryDesc.value = null
|
||||
openDocDetail(node.doc)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [cats, stats] = await Promise.all([fetchCategories(), fetchStats()])
|
||||
categoriesData.value = cats.categories || []
|
||||
// 加载全部文档(分页直到取完)
|
||||
const docs = []
|
||||
let offset = null
|
||||
for (;;) {
|
||||
const page = await fetchDocuments(100, offset)
|
||||
docs.push(...(page.items || []))
|
||||
offset = page.next_offset ?? null
|
||||
if (!offset) break
|
||||
}
|
||||
allDocs.value = docs
|
||||
// 更新类目计数(可选)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '加载知识库失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
loadAll()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
|
||||
/* ---------- 文档详情 ---------- */
|
||||
const docDetailVisible = ref(false)
|
||||
const docDetailData = ref(null)
|
||||
const isLoadingDetail = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const isReingesting = ref(false)
|
||||
|
||||
function openDocDetail(doc) {
|
||||
docDetailVisible.value = true
|
||||
docDetailData.value = null
|
||||
loadDocDetail(doc.doc_id)
|
||||
}
|
||||
|
||||
function closeDocDetail() {
|
||||
docDetailVisible.value = false
|
||||
docDetailData.value = null
|
||||
}
|
||||
|
||||
async function loadDocDetail(docId) {
|
||||
isLoadingDetail.value = true
|
||||
try {
|
||||
docDetailData.value = await fetchDocumentDetail(docId)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '加载文档详情失败')
|
||||
} finally {
|
||||
isLoadingDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(doc) {
|
||||
const docId = doc.doc_id
|
||||
const title = doc.title || docId
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定删除文档「${title}」(${docId}) 吗?该操作将删除四层集合中的全部数据,不可恢复。`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await deleteDocument(docId)
|
||||
message.success('已删除')
|
||||
closeDocDetail()
|
||||
await loadAll()
|
||||
} catch (err) {
|
||||
message.error(err?.message || '删除文档失败')
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleReingest(doc) {
|
||||
const docId = doc.doc_id
|
||||
const title = doc.title || docId
|
||||
Modal.confirm({
|
||||
title: '重新摘要入库',
|
||||
content: `将重新读取文档「${title}」(${docId}) 的原始文件,删除旧数据后重新生成三级总结并入库。生成新的 doc_id。是否继续?`,
|
||||
okText: '重新入库',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
isReingesting.value = true
|
||||
try {
|
||||
const data = await reingestDocument(docId)
|
||||
message.success(`已提交重新入库任务:${data.task_id}`)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '重新入库失败')
|
||||
} finally {
|
||||
isReingesting.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 入库抽屉 ---------- */
|
||||
const ingestVisible = ref(false)
|
||||
const activeTab = ref('text')
|
||||
const { state: pollState, startPolling, stopPolling } = useIngestPolling()
|
||||
|
||||
const textFormRef = ref(null)
|
||||
const textForm = reactive({ title: '', source: '', text: '' })
|
||||
const textRules = {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
text: [{ required: true, message: '请输入正文', trigger: 'blur' }]
|
||||
}
|
||||
const isSubmittingText = ref(false)
|
||||
const isSubmittingFile = ref(false)
|
||||
const isSubmittingBatch = ref(false)
|
||||
|
||||
const fileForm = reactive({ title: '', source: '' })
|
||||
const fileList = ref([])
|
||||
const rawFile = ref(null)
|
||||
const batchResult = ref(null)
|
||||
const ACCEPTED_EXTENSIONS = '.txt,.md,.html,.htm,.pdf,.docx'
|
||||
|
||||
const statusBadgeColor = computed(() => INGEST_STATUS_COLOR[pollState.status] || 'default')
|
||||
const statusBadgeText = computed(() => INGEST_STATUS_TEXT[pollState.status] || pollState.status || '-')
|
||||
const isTerminal = computed(() => ['done', 'failed'].includes(pollState.status))
|
||||
const resultData = computed(() => pollState.task?.result || null)
|
||||
const errorData = computed(() => pollState.task?.error || pollState.error || null)
|
||||
const summaryData = computed(() => resultData.value?.summary || {})
|
||||
const partialSummary = computed(() => errorData.value?.partial_summary || null)
|
||||
|
||||
function openIngest() {
|
||||
ingestVisible.value = true
|
||||
}
|
||||
|
||||
function handleFileChange(file) {
|
||||
rawFile.value = file
|
||||
fileList.value = [file]
|
||||
return false
|
||||
}
|
||||
|
||||
function handleFileRemove() {
|
||||
rawFile.value = null
|
||||
fileList.value = []
|
||||
}
|
||||
|
||||
function handleBatchFileChange(_file, fileListArg) {
|
||||
fileList.value = fileListArg.map((f) => f.originFileObj || f)
|
||||
batchResult.value = null
|
||||
return false
|
||||
}
|
||||
|
||||
function handleBatchRemove(file) {
|
||||
fileList.value = fileList.value.filter((f) => f !== file && f.name !== file.name)
|
||||
batchResult.value = null
|
||||
}
|
||||
|
||||
function handleBatchClear() {
|
||||
fileList.value = []
|
||||
batchResult.value = null
|
||||
}
|
||||
|
||||
async function handleSubmitText() {
|
||||
try {
|
||||
await textFormRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
isSubmittingText.value = true
|
||||
try {
|
||||
const payload = { title: textForm.title, text: textForm.text }
|
||||
if (textForm.source) payload.source = textForm.source
|
||||
const data = await ingestDocument(payload)
|
||||
message.success('任务已提交')
|
||||
startPolling(data.task_id)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '提交入库失败')
|
||||
} finally {
|
||||
isSubmittingText.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitFile() {
|
||||
if (!rawFile.value) {
|
||||
message.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
isSubmittingFile.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', rawFile.value)
|
||||
if (fileForm.title) formData.append('title', fileForm.title)
|
||||
if (fileForm.source) formData.append('source', fileForm.source)
|
||||
const data = await uploadDocument(formData)
|
||||
message.success('任务已提交')
|
||||
startPolling(data.task_id)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '上传入库失败')
|
||||
} finally {
|
||||
isSubmittingFile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitBatch() {
|
||||
if (!fileList.value.length) {
|
||||
message.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
isSubmittingBatch.value = true
|
||||
try {
|
||||
const data = await uploadBatch(fileList.value)
|
||||
batchResult.value = data
|
||||
const okCount = data.tasks?.length || 0
|
||||
const failCount = data.failed?.length || 0
|
||||
if (okCount > 0) {
|
||||
message.success(`已提交 ${okCount} 个任务${failCount > 0 ? `,${failCount} 个失败` : ''}`)
|
||||
} else if (failCount > 0) {
|
||||
message.error(`全部 ${failCount} 个文件上传失败`)
|
||||
}
|
||||
if (data.tasks?.length === 1 && failCount === 0) {
|
||||
startPolling(data.tasks[0].task_id)
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(err?.message || '批量上传失败')
|
||||
} finally {
|
||||
isSubmittingBatch.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelPolling() {
|
||||
stopPolling()
|
||||
message.info('已停止轮询')
|
||||
}
|
||||
|
||||
const batchColumns = [
|
||||
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
|
||||
{ title: 'task_id', dataIndex: 'task_id', key: 'task_id', width: 240 }
|
||||
]
|
||||
const failedColumns = [
|
||||
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
|
||||
{ title: '错误', dataIndex: 'error', key: 'error' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="library page-section">
|
||||
<div class="library__header">
|
||||
<h2 class="library__title">知识库</h2>
|
||||
<div class="library__actions">
|
||||
<a-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索标题 / 摘要 / 类目"
|
||||
allow-clear
|
||||
style="width: 260px"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-button :loading="isLoading" @click="handleRefresh">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button @click="tasksDrawer.open()">
|
||||
<template #icon><ClockCircleOutlined /></template>
|
||||
入库进度
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openIngest">
|
||||
<template #icon><UploadOutlined /></template>
|
||||
入库
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="library__body">
|
||||
<div class="library__tree">
|
||||
<a-spin :spinning="isLoading">
|
||||
<a-tree
|
||||
:tree-data="treeDataComputed"
|
||||
:default-expand-all="true"
|
||||
:show-icon="true"
|
||||
block-node
|
||||
@select="handleTreeSelect"
|
||||
>
|
||||
<template #title="{ title, count, category }">
|
||||
<span v-if="category !== undefined" class="library__node-cat">
|
||||
<span>{{ title }}</span>
|
||||
<span class="text-muted library__node-count">{{ count }}</span>
|
||||
</span>
|
||||
<span v-else>{{ title }}</span>
|
||||
</template>
|
||||
</a-tree>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<div class="library__detail">
|
||||
<!-- 类目说明 -->
|
||||
<div v-if="selectedCategory" class="library__panel">
|
||||
<h3 class="library__panel-title">
|
||||
<TagsOutlined /> {{ selectedCategory }}
|
||||
</h3>
|
||||
<div class="text-muted">{{ categoryDesc || '(无描述)' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 未选中提示 -->
|
||||
<div v-else-if="!docDetailVisible" class="library__placeholder text-muted">
|
||||
从左侧选择类目或文档查看详情
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文档详情抽屉 -->
|
||||
<a-drawer
|
||||
:open="docDetailVisible"
|
||||
title="文档详情"
|
||||
placement="right"
|
||||
width="620"
|
||||
:destroy-on-close="true"
|
||||
@close="closeDocDetail"
|
||||
>
|
||||
<a-spin :spinning="isLoadingDetail">
|
||||
<div v-if="docDetailData">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="doc_id">{{ docDetailData.l1?.doc_id || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="标题">{{ docDetailData.l1?.title || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="类目">{{ docDetailData.l1?.category || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="标签">
|
||||
<template v-if="docDetailData.l1?.tags && docDetailData.l1.tags.length">
|
||||
<a-tag v-for="tag in docDetailData.l1.tags" :key="tag">{{ tag }}</a-tag>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="chunks_count">{{ docDetailData.chunks_count ?? 0 }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="docDetailData.file" label="原文文件">
|
||||
<a :href="docDetailData.file.url" target="_blank" rel="noopener">
|
||||
{{ docDetailData.file.filename }}
|
||||
<span v-if="docDetailData.file.size_bytes" class="text-muted">
|
||||
({{ formatSize(docDetailData.file.size_bytes) }})
|
||||
</span>
|
||||
</a>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="library__detail-actions">
|
||||
<a-button size="small" :loading="isReingesting" @click="handleReingest(docDetailData.l1)">
|
||||
重新摘要
|
||||
</a-button>
|
||||
<a-button size="small" danger :loading="isDeleting" @click="handleDelete(docDetailData.l1)">
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<h3 class="library__section-title">L1 全文</h3>
|
||||
<pre class="library__pre">{{ docDetailData.l1?.text || '' }}</pre>
|
||||
|
||||
<h3 class="library__section-title">L2 节点({{ (docDetailData.l2_nodes || []).length }})</h3>
|
||||
<div v-if="(docDetailData.l2_nodes || []).length === 0" class="text-muted">无</div>
|
||||
<div v-for="(node, idx) in docDetailData.l2_nodes || []" :key="`l2-${idx}`" class="library__node">
|
||||
<div class="library__node-path">{{ node.section_path || '' }}</div>
|
||||
<div class="text-break">{{ node.text || '' }}</div>
|
||||
</div>
|
||||
|
||||
<h3 class="library__section-title">L3 节点({{ (docDetailData.l3_nodes || []).length }})</h3>
|
||||
<div v-if="(docDetailData.l3_nodes || []).length === 0" class="text-muted">无</div>
|
||||
<div v-for="(node, idx) in docDetailData.l3_nodes || []" :key="`l3-${idx}`" class="library__node">
|
||||
<div class="library__node-path">{{ node.section_path || '' }}</div>
|
||||
<div class="text-break">{{ node.text || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isLoadingDetail" class="text-muted">暂无数据</div>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 入库抽屉 -->
|
||||
<a-drawer
|
||||
:open="ingestVisible"
|
||||
title="文档入库"
|
||||
placement="right"
|
||||
width="560"
|
||||
:destroy-on-close="true"
|
||||
@close="() => (ingestVisible = false)"
|
||||
>
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="text" tab="文本入库">
|
||||
<a-form ref="textFormRef" :model="textForm" :rules="textRules" layout="vertical">
|
||||
<a-form-item label="标题" name="title">
|
||||
<a-input v-model:value="textForm.title" placeholder="请输入标题" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item label="来源" name="source">
|
||||
<a-input v-model:value="textForm.source" placeholder="例如:manual / web / file" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item label="正文" name="text">
|
||||
<a-textarea v-model:value="textForm.text" placeholder="请输入文档正文" :auto-size="{ minRows: 8, maxRows: 18 }" />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="isSubmittingText || pollState.isPolling" @click="handleSubmitText">
|
||||
提交入库
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="file" tab="文件上传">
|
||||
<a-form :model="fileForm" layout="vertical">
|
||||
<a-form-item label="选择文件">
|
||||
<a-upload :file-list="fileList" :accept="ACCEPTED_EXTENSIONS" :max-count="1" :before-upload="handleFileChange" @remove="handleFileRemove">
|
||||
<a-button :disabled="fileList.length >= 1">选择文件</a-button>
|
||||
</a-upload>
|
||||
<div class="text-muted" style="margin-top: 4px">支持 .txt/.md/.html/.htm/.pdf/.docx</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="标题(可选,默认取文件名)" name="title">
|
||||
<a-input v-model:value="fileForm.title" placeholder="留空则使用文件名去扩展" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item label="来源(可选,默认 file:原文件名)" name="source">
|
||||
<a-input v-model:value="fileForm.source" placeholder="例如:manual / web" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="isSubmittingFile || pollState.isPolling" @click="handleSubmitFile">
|
||||
上传入库
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="batch" tab="批量上传">
|
||||
<a-form :model="fileForm" layout="vertical">
|
||||
<a-form-item label="选择文件(可多选)">
|
||||
<a-upload multiple :file-list="fileList" :accept="ACCEPTED_EXTENSIONS" :before-upload="handleBatchFileChange" @remove="handleBatchRemove">
|
||||
<a-button>选择文件</a-button>
|
||||
</a-upload>
|
||||
<div class="text-muted" style="margin-top: 4px">支持 .txt/.md/.html/.htm/.pdf/.docx,可多选,逐文件异步入库</div>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="fileList.length">
|
||||
<a-button type="primary" :loading="isSubmittingBatch" @click="handleSubmitBatch">
|
||||
批量上传入库({{ fileList.length }} 个文件)
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" :disabled="isSubmittingBatch" @click="handleBatchClear">清空</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div v-if="batchResult" class="library__batch">
|
||||
<a-alert
|
||||
v-if="batchResult.failed && batchResult.failed.length"
|
||||
class="library__alert"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="`${batchResult.failed.length} 个文件上传失败`"
|
||||
/>
|
||||
<a-table
|
||||
v-if="batchResult.tasks && batchResult.tasks.length"
|
||||
:data-source="batchResult.tasks"
|
||||
:columns="batchColumns"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
row-key="task_id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'task_id'">
|
||||
<a-typography-text copyable :style="{ fontSize: '12px' }">{{ record.task_id }}</a-typography-text>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<a-table
|
||||
v-if="batchResult.failed && batchResult.failed.length"
|
||||
:data-source="batchResult.failed"
|
||||
:columns="failedColumns"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
row-key="filename"
|
||||
style="margin-top: 12px"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'error'">
|
||||
<span class="text-break">{{ record.error }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<div v-if="pollState.taskId" class="library__result">
|
||||
<div class="library__result-header">
|
||||
<span>任务已提交:{{ pollState.taskId }}</span>
|
||||
<a-tag :color="statusBadgeColor">{{ statusBadgeText }}</a-tag>
|
||||
<a-button v-if="pollState.isPolling" type="link" size="small" @click="handleCancelPolling">停止轮询</a-button>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="pollState.isTimeout"
|
||||
class="library__alert"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="`任务仍在进行,可稍后凭 task_id 查询:${pollState.taskId}`"
|
||||
/>
|
||||
<div v-if="isTerminal && pollState.status === 'done' && resultData" class="library__done">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="document_id">{{ resultData.document_id || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="类目">{{ resultData.category || '-' }}(置信度 {{ resultData.category_confidence ?? '-' }})</a-descriptions-item>
|
||||
<a-descriptions-item label="标签">
|
||||
<template v-if="resultData.tags && resultData.tags.length">
|
||||
<a-tag v-for="tag in resultData.tags" :key="tag">{{ tag }}</a-tag>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="总结层级">{{ summaryData.level ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="chunks_count">{{ resultData.chunks_count ?? 0 }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<h3 class="library__section-title">L1 总结</h3>
|
||||
<pre class="library__pre">{{ summaryData.l1_summary || '' }}</pre>
|
||||
</div>
|
||||
<div v-if="isTerminal && pollState.status === 'failed'" class="library__failed">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="失败阶段">{{ errorData?.stage || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="错误信息"><span class="text-break">{{ errorData?.message || '-' }}</span></a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<template v-if="partialSummary && partialSummary.l1_summary">
|
||||
<h3 class="library__section-title">L1 总结</h3>
|
||||
<pre class="library__pre">{{ partialSummary.l1_summary }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.library__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.library__title {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.library__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.library__body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.library__tree {
|
||||
width: 320px;
|
||||
flex: 0 0 auto;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.library__node-cat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.library__node-count {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.library__detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.library__panel {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.library__panel-title {
|
||||
font-size: 15px;
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.library__placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.library__detail-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.library__section-title {
|
||||
font-size: 14px;
|
||||
margin: 16px 0 8px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.library__pre {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.library__node {
|
||||
border-left: 3px solid #93c5fd;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 6px;
|
||||
background: #f8fafc;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.library__node-path {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.library__result {
|
||||
margin-top: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.library__result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.library__alert {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.library__batch {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.library__body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.library__tree {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user