Files
QMDSearch/frontend/src/views/Library.vue
T
kplam 35b7decd49 feat: 前端页面优化与入库任务重试/删除
- 概览与检索合并为单一概览页
- 类目/文档管理/入库整合为树形目录知识库页(Library),支持文档搜索
- 入库进度改为右侧 Drawer,支持任务详情、重试、删除
- 后端新增任务重试与删除接口
2026-08-04 12:58:11 +08:00

815 lines
25 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.
<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>