35b7decd49
- 概览与检索合并为单一概览页 - 类目/文档管理/入库整合为树形目录知识库页(Library),支持文档搜索 - 入库进度改为右侧 Drawer,支持任务详情、重试、删除 - 后端新增任务重试与删除接口
815 lines
25 KiB
Vue
815 lines
25 KiB
Vue
<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> |