feat: 前端页面优化与入库任务重试/删除
- 概览与检索合并为单一概览页 - 类目/文档管理/入库整合为树形目录知识库页(Library),支持文档搜索 - 入库进度改为右侧 Drawer,支持任务详情、重试、删除 - 后端新增任务重试与删除接口
This commit is contained in:
@@ -83,6 +83,24 @@ export function taskList(limit = 20) {
|
||||
return http.get('/api/v1/documents/tasks', { params: { limit } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试入库任务(用原 source 重新提交新任务)
|
||||
* @param {string} taskId
|
||||
* @returns {Promise<{task_id:string, status:string}>}
|
||||
*/
|
||||
export function retryTask(taskId) {
|
||||
return http.post(`/api/v1/documents/tasks/${encodeURIComponent(taskId)}/retry`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除入库任务
|
||||
* @param {string} taskId
|
||||
* @returns {Promise<{task_id:string, deleted:boolean}>}
|
||||
*/
|
||||
export function deleteTask(taskId) {
|
||||
return http.delete(`/api/v1/documents/tasks/${encodeURIComponent(taskId)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询入库任务状态
|
||||
* @param {string} taskId
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
/**
|
||||
* 入库进度右侧 Drawer 的全局共享开关状态
|
||||
*
|
||||
* 该 Drawer 渲染在 MainLayout 中,但可由任意子页面(如 Library)触发打开,
|
||||
* 因此通过单例 reactive 状态 + open()/close() 方法解耦,避免多层 prop 传递。
|
||||
*/
|
||||
const state = reactive({ open: false })
|
||||
|
||||
export function useTasksDrawer() {
|
||||
return {
|
||||
state,
|
||||
open() {
|
||||
state.open = true
|
||||
},
|
||||
close() {
|
||||
state.open = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,6 @@ import { storeToRefs } from 'pinia'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
FileTextOutlined,
|
||||
UploadOutlined,
|
||||
SearchOutlined,
|
||||
AppstoreOutlined,
|
||||
SettingOutlined,
|
||||
MenuFoldOutlined,
|
||||
@@ -21,11 +18,14 @@ import {
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { changeMyPassword } from '@/api/auth'
|
||||
import { callApi } from '@/api/client'
|
||||
import TasksDrawer from '@/views/TasksDrawer.vue'
|
||||
import { useTasksDrawer } from '@/composables/useTasksDrawer'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { user, displayName } = storeToRefs(authStore)
|
||||
const tasksDrawer = useTasksDrawer()
|
||||
|
||||
const collapsed = ref(false)
|
||||
|
||||
@@ -37,12 +37,9 @@ const openKeys = ref(['main'])
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items = [
|
||||
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览' },
|
||||
{ key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' },
|
||||
{ key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' },
|
||||
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' },
|
||||
{ key: 'library', icon: () => h(AppstoreOutlined), label: '知识库' },
|
||||
{ key: 'tasks', icon: () => h(ClockCircleOutlined), label: '入库进度' },
|
||||
{ key: 'search', icon: () => h(SearchOutlined), label: '检索测试台' },
|
||||
{ key: 'categories', icon: () => h(AppstoreOutlined), label: '类目列表' },
|
||||
{ key: 'settings', icon: () => h(SettingOutlined), label: '设置' },
|
||||
{ key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' }
|
||||
]
|
||||
@@ -53,6 +50,10 @@ const menuItems = computed(() => {
|
||||
})
|
||||
|
||||
function handleMenuClick({ key }) {
|
||||
if (key === 'tasks') {
|
||||
tasksDrawer.open()
|
||||
return
|
||||
}
|
||||
if (key && key !== route.name) {
|
||||
router.push({ name: key })
|
||||
}
|
||||
@@ -194,6 +195,9 @@ async function handleSubmitPassword() {
|
||||
</a-layout-footer>
|
||||
</a-layout>
|
||||
|
||||
<!-- 入库进度右侧 Drawer -->
|
||||
<TasksDrawer />
|
||||
|
||||
<!-- 强制改密弹窗(must_change_password 用户,不可关闭) -->
|
||||
<a-modal
|
||||
v-model:open="passwordModalVisible"
|
||||
|
||||
@@ -18,37 +18,13 @@ const routes = [
|
||||
path: 'overview',
|
||||
name: 'overview',
|
||||
component: () => import('@/views/Overview.vue'),
|
||||
meta: { title: '概览', requiresAuth: true }
|
||||
meta: { title: '概览与检索', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'documents',
|
||||
name: 'documents',
|
||||
component: () => import('@/views/Documents.vue'),
|
||||
meta: { title: '文档管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'ingest',
|
||||
name: 'ingest',
|
||||
component: () => import('@/views/Ingest.vue'),
|
||||
meta: { title: '文档入库', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'tasks',
|
||||
component: () => import('@/views/Tasks.vue'),
|
||||
meta: { title: '入库进度', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'search',
|
||||
component: () => import('@/views/Search.vue'),
|
||||
meta: { title: '检索测试台', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'categories',
|
||||
component: () => import('@/views/Categories.vue'),
|
||||
meta: { title: '类目列表', requiresAuth: true }
|
||||
path: 'library',
|
||||
name: 'library',
|
||||
component: () => import('@/views/Library.vue'),
|
||||
meta: { title: '知识库', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
|
||||
@@ -10,6 +10,20 @@ export function truncate(text, maxLen = 80) {
|
||||
return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化字节数为人类可读大小
|
||||
* @param {number|null|undefined} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSize(bytes) {
|
||||
if (bytes == null || bytes === '') return '-'
|
||||
const n = Number(bytes)
|
||||
if (Number.isNaN(n)) return '-'
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全拼接类名
|
||||
* @param {...(string | false | null | undefined)} args
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { categories as fetchCategories } from '@/api/knowledge'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const categoriesData = ref([])
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '描述', dataIndex: 'description', key: 'description' }
|
||||
]
|
||||
|
||||
async function loadCategories() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await fetchCategories()
|
||||
categoriesData.value = data.categories || []
|
||||
} catch (err) {
|
||||
message.error(err?.message || '加载类目列表失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="categories page-section">
|
||||
<div class="categories__header">
|
||||
<h2 class="categories__title">类目列表</h2>
|
||||
<a-button :loading="isLoading" @click="loadCategories">刷新</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="categoriesData"
|
||||
:pagination="false"
|
||||
:loading="isLoading"
|
||||
row-key="name"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<a-tag color="blue">{{ record.name }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'description'">
|
||||
<span>{{ record.description || '-' }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.categories__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.categories__title {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,346 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { FileOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
list as fetchDocuments,
|
||||
detail as fetchDocumentDetail,
|
||||
remove as deleteDocument,
|
||||
reingest as reingestDocument
|
||||
} from '@/api/documents'
|
||||
import { truncate } from '@/utils/format'
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isLoadingDetail = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const isReingesting = ref(false)
|
||||
|
||||
const documents = ref([])
|
||||
const nextOffset = ref(null)
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailData = ref(null)
|
||||
|
||||
const statusText = reactive({
|
||||
text: ''
|
||||
})
|
||||
|
||||
function updateStatusText() {
|
||||
statusText.text =
|
||||
nextOffset.value === null ? '已加载全部' : '还有更多,可继续加载'
|
||||
}
|
||||
|
||||
async function loadDocuments(reset = false) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const offset = reset ? null : nextOffset.value
|
||||
const data = await fetchDocuments(20, offset)
|
||||
if (reset) {
|
||||
documents.value = data.items || []
|
||||
} else {
|
||||
documents.value = documents.value.concat(data.items || [])
|
||||
}
|
||||
nextOffset.value = data.next_offset ?? null
|
||||
updateStatusText()
|
||||
} catch (err) {
|
||||
message.error(err?.message || '加载文档列表失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadMore() {
|
||||
await loadDocuments(false)
|
||||
}
|
||||
|
||||
function handleViewDetail(docId) {
|
||||
detailVisible.value = true
|
||||
detailData.value = null
|
||||
loadDocDetail(docId)
|
||||
}
|
||||
|
||||
async function loadDocDetail(docId) {
|
||||
isLoadingDetail.value = true
|
||||
try {
|
||||
detailData.value = await fetchDocumentDetail(docId)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '加载文档详情失败')
|
||||
} finally {
|
||||
isLoadingDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(doc) {
|
||||
const title = doc.title || doc.doc_id
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定删除文档「${title}」(${doc.doc_id}) 吗?该操作将删除四层集合中的全部数据,不可恢复。`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
isDeleting.value = true
|
||||
try {
|
||||
const data = await deleteDocument(doc.doc_id)
|
||||
message.success(`已删除 ${data.deleted_total ?? 0} 条数据`)
|
||||
detailVisible.value = false
|
||||
await loadDocuments(true)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '删除文档失败')
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 重新摘要入库:读原文件→删旧数据→提交新任务
|
||||
function handleReingest(doc) {
|
||||
const title = doc.title || doc.doc_id
|
||||
Modal.confirm({
|
||||
title: '重新摘要入库',
|
||||
content: `将重新读取文档「${title}」(${doc.doc_id}) 的原始文件,删除旧数据后重新生成三级总结并入库。生成新的 doc_id。是否继续?`,
|
||||
okText: '重新入库',
|
||||
cancelText: '取消',
|
||||
okType: 'primary',
|
||||
async onOk() {
|
||||
try {
|
||||
const data = await reingestDocument(doc.doc_id)
|
||||
message.success(`已提交重新入库任务:${data.task_id}`)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '重新入库失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleCloseDetail() {
|
||||
detailVisible.value = false
|
||||
detailData.value = null
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{ title: '类目', dataIndex: 'category', key: 'category', width: 140 },
|
||||
{ title: '标签', dataIndex: 'tags', key: 'tags', width: 220 },
|
||||
{ title: 'L1 摘要', dataIndex: 'summary', key: 'summary', ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 200, fixed: 'right' }
|
||||
]
|
||||
|
||||
function getDocTitle(record) {
|
||||
return record?.title || '(无标题)'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDocuments(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="documents page-section">
|
||||
<div class="documents__header">
|
||||
<h2 class="documents__title">文档管理</h2>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="documents"
|
||||
:pagination="false"
|
||||
:loading="isLoading"
|
||||
row-key="doc_id"
|
||||
size="small"
|
||||
:scroll="{ x: 900 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<span :title="record.title">{{ getDocTitle(record) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'category'">
|
||||
<a-tag v-if="record.category" color="blue">{{ record.category }}</a-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'tags'">
|
||||
<template v-if="record.tags && record.tags.length">
|
||||
<a-tag v-for="tag in record.tags" :key="tag">{{ tag }}</a-tag>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'summary'">
|
||||
<span :title="record.summary">{{ truncate(record.summary, 80) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record.doc_id)">
|
||||
详情
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
:loading="isReingesting"
|
||||
@click="handleReingest(record)"
|
||||
>
|
||||
重新摘要
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
:loading="isDeleting"
|
||||
@click="handleDelete(record)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<div class="toolbar documents__toolbar">
|
||||
<a-button
|
||||
:loading="isLoading"
|
||||
:disabled="nextOffset === null"
|
||||
@click="handleLoadMore"
|
||||
>
|
||||
加载更多
|
||||
</a-button>
|
||||
<span class="text-muted" style="margin-left: 12px">{{ statusText.text }}</span>
|
||||
</div>
|
||||
|
||||
<a-drawer
|
||||
:open="detailVisible"
|
||||
title="文档详情"
|
||||
placement="right"
|
||||
width="640"
|
||||
:destroy-on-close="true"
|
||||
@close="handleCloseDetail"
|
||||
>
|
||||
<a-spin :spinning="isLoadingDetail">
|
||||
<div v-if="detailData">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="doc_id">
|
||||
{{ detailData.l1?.doc_id || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="标题">
|
||||
{{ detailData.l1?.title || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="类目">
|
||||
{{ detailData.l1?.category || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="标签">
|
||||
<template v-if="detailData.l1?.tags && detailData.l1.tags.length">
|
||||
<a-tag v-for="tag in detailData.l1.tags" :key="tag">{{ tag }}</a-tag>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="chunks_count">
|
||||
{{ detailData.chunks_count ?? 0 }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="原文文件" v-if="detailData.file">
|
||||
<a :href="detailData.file.url" target="_blank" rel="noopener">
|
||||
<FileOutlined />
|
||||
{{ detailData.file.filename }}
|
||||
<span class="text-muted" v-if="detailData.file.size_bytes">
|
||||
({{ formatSize(detailData.file.size_bytes) }})
|
||||
</span>
|
||||
</a>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<h3 class="documents__section-title">L1 全文</h3>
|
||||
<pre class="documents__pre">{{ detailData.l1?.text || '' }}</pre>
|
||||
|
||||
<h3 class="documents__section-title">
|
||||
L2 节点({{ (detailData.l2_nodes || []).length }})
|
||||
</h3>
|
||||
<div v-if="(detailData.l2_nodes || []).length === 0" class="text-muted">无</div>
|
||||
<div
|
||||
v-for="(node, idx) in detailData.l2_nodes || []"
|
||||
:key="`l2-${idx}`"
|
||||
class="documents__node"
|
||||
>
|
||||
<div class="documents__node-path">{{ node.section_path || '' }}</div>
|
||||
<div class="documents__node-text text-break">{{ node.text || '' }}</div>
|
||||
</div>
|
||||
|
||||
<h3 class="documents__section-title">
|
||||
L3 节点({{ (detailData.l3_nodes || []).length }})
|
||||
</h3>
|
||||
<div v-if="(detailData.l3_nodes || []).length === 0" class="text-muted">无</div>
|
||||
<div
|
||||
v-for="(node, idx) in detailData.l3_nodes || []"
|
||||
:key="`l3-${idx}`"
|
||||
class="documents__node"
|
||||
>
|
||||
<div class="documents__node-path">{{ node.section_path || '' }}</div>
|
||||
<div class="documents__node-text text-break">{{ node.text || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isLoadingDetail" class="text-muted">暂无数据</div>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.documents__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.documents__title {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.documents__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.documents__section-title {
|
||||
font-size: 14px;
|
||||
margin: 16px 0 8px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.documents__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;
|
||||
}
|
||||
|
||||
.documents__node {
|
||||
border-left: 3px solid #93c5fd;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 6px;
|
||||
background: #f8fafc;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.documents__node-path {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.documents__node-text {
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
@@ -1,495 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ingest as ingestDocument,
|
||||
upload as uploadDocument,
|
||||
uploadBatch
|
||||
} from '@/api/documents'
|
||||
import { useIngestPolling } from '@/composables/useIngestPolling'
|
||||
import {
|
||||
INGEST_STATUS_TEXT,
|
||||
INGEST_STATUS_COLOR
|
||||
} from '@/constants/ingest'
|
||||
|
||||
const { state: pollState, startPolling, stopPolling } = useIngestPolling()
|
||||
|
||||
const activeTab = ref('text')
|
||||
|
||||
const isSubmittingText = ref(false)
|
||||
const isSubmittingFile = ref(false)
|
||||
|
||||
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 fileForm = reactive({
|
||||
title: '',
|
||||
source: ''
|
||||
})
|
||||
const fileList = ref([])
|
||||
const rawFile = ref(null)
|
||||
|
||||
// 批量上传结果:{tasks: [{filename, task_id}], failed: [{filename, error}]}
|
||||
const batchResult = ref(null)
|
||||
const isSubmittingBatch = ref(false)
|
||||
|
||||
const ACCEPTED_EXTENSIONS = '.txt,.md,.html,.htm,.pdf,.docx'
|
||||
|
||||
const statusBadgeColor = computed(() => {
|
||||
return INGEST_STATUS_COLOR[pollState.status] || 'default'
|
||||
})
|
||||
|
||||
const batchColumns = [
|
||||
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
|
||||
{ title: 'task_id', dataIndex: 'task_id', key: 'task_id', width: 260 }
|
||||
]
|
||||
|
||||
const failedColumns = [
|
||||
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
|
||||
{ title: '错误', dataIndex: 'error', key: 'error' }
|
||||
]
|
||||
|
||||
const statusBadgeText = computed(() => {
|
||||
return 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 handleFileChange(file) {
|
||||
// a-upload before-upload 返回 false 表示不自动上传;保存原始 File 供 FormData 使用
|
||||
rawFile.value = file
|
||||
fileList.value = [file]
|
||||
return false
|
||||
}
|
||||
|
||||
function handleFileRemove(file) {
|
||||
rawFile.value = null
|
||||
fileList.value = []
|
||||
batchResult.value = null
|
||||
}
|
||||
|
||||
// 批量上传:before-upload 返回 false,收集多个 File 到 fileList
|
||||
function handleBatchFileChange(file, fileListArg) {
|
||||
const files = fileListArg.map((f) => f.originFileObj || f)
|
||||
fileList.value = files
|
||||
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('已停止轮询')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ingest page-section">
|
||||
<div class="ingest__header">
|
||||
<h2 class="ingest__title">文档入库</h2>
|
||||
</div>
|
||||
|
||||
<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="ingest__result" style="margin-top: 16px">
|
||||
<div class="ingest__result-header">
|
||||
<span>批量上传结果</span>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="batchResult.failed && batchResult.failed.length"
|
||||
class="ingest__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 === 'filename'">
|
||||
<span :title="record.filename">{{ record.filename }}</span>
|
||||
</template>
|
||||
<template v-else-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="ingest__result">
|
||||
<div class="ingest__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="ingest__alert"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="`任务仍在进行,可稍后凭 task_id 查询:${pollState.taskId}`"
|
||||
/>
|
||||
|
||||
<a-alert
|
||||
v-if="pollState.error && !pollState.task"
|
||||
class="ingest__alert"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="pollState.error?.message || '轮询失败'"
|
||||
/>
|
||||
|
||||
<div v-if="isTerminal && pollState.status === 'done' && resultData" class="ingest__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="写入集合">
|
||||
{{ resultData.collection || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="chunks_count">
|
||||
{{ resultData.chunks_count ?? 0 }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<h3 class="ingest__section-title">L1 总结</h3>
|
||||
<pre class="ingest__pre">{{ summaryData.l1_summary || '' }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="isTerminal && pollState.status === 'failed'" class="ingest__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">
|
||||
<a-alert
|
||||
class="ingest__alert"
|
||||
type="info"
|
||||
show-icon
|
||||
message="已产出总结保留:任务失败前已生成 L1 摘要"
|
||||
/>
|
||||
<h3 class="ingest__section-title">L1 总结</h3>
|
||||
<pre class="ingest__pre">{{ partialSummary.l1_summary }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ingest__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ingest__title {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ingest__result {
|
||||
margin-top: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ingest__result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ingest__alert {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.ingest__done,
|
||||
.ingest__failed {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ingest__section-title {
|
||||
font-size: 14px;
|
||||
margin: 12px 0 8px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.ingest__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;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
+230
-71
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
@@ -9,69 +9,82 @@ import {
|
||||
FileTextOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
AppstoreOutlined
|
||||
AppstoreOutlined,
|
||||
SearchOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { stats as fetchStats } from '@/api/knowledge'
|
||||
import { search as searchApi } from '@/api/search'
|
||||
|
||||
/* ---------- 检索 ---------- */
|
||||
const isSearching = ref(false)
|
||||
const resultData = ref(null)
|
||||
const searchFormRef = ref(null)
|
||||
const searchForm = reactive({
|
||||
query: '',
|
||||
top_k: 5,
|
||||
summarize: false
|
||||
})
|
||||
const searchRules = {
|
||||
query: [{ required: true, message: '请输入查询语句', trigger: 'blur' }],
|
||||
top_k: [{ type: 'number', min: 1, max: 50, message: 'top_k 范围 1~50', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const routedCategoriesText = (cats) => {
|
||||
if (!cats || !cats.length) return '(无)'
|
||||
return cats.join(', ')
|
||||
}
|
||||
|
||||
function eiValue(value) {
|
||||
if (value === undefined || value === null || value === '') return '(无)'
|
||||
if (Array.isArray(value)) {
|
||||
return value.length ? value.join(', ') : '(无)'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const hits = (data) => data?.hits || []
|
||||
|
||||
async function handleSearch() {
|
||||
try {
|
||||
await searchFormRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
isSearching.value = true
|
||||
resultData.value = null
|
||||
try {
|
||||
resultData.value = await searchApi({
|
||||
query: searchForm.query,
|
||||
top_k: searchForm.top_k,
|
||||
summarize: searchForm.summarize
|
||||
})
|
||||
} catch (err) {
|
||||
message.error(err?.message || '检索失败')
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 概览统计 ---------- */
|
||||
const isLoading = ref(false)
|
||||
const statsData = ref(null)
|
||||
|
||||
const cardMeta = [
|
||||
{
|
||||
key: 'doc_l1',
|
||||
label: 'L1 文档总结',
|
||||
icon: DatabaseOutlined,
|
||||
color: '#1677ff',
|
||||
bg: 'rgba(22, 119, 255, 0.12)'
|
||||
},
|
||||
{
|
||||
key: 'doc_l2',
|
||||
label: 'L2 大纲节点',
|
||||
icon: ApartmentOutlined,
|
||||
color: '#722ed1',
|
||||
bg: 'rgba(114, 46, 209, 0.12)'
|
||||
},
|
||||
{
|
||||
key: 'doc_l3',
|
||||
label: 'L3 内容大纲',
|
||||
icon: FileSearchOutlined,
|
||||
color: '#13c2c2',
|
||||
bg: 'rgba(19, 194, 194, 0.12)'
|
||||
},
|
||||
{
|
||||
key: 'chunks',
|
||||
label: 'Chunks',
|
||||
icon: BlockOutlined,
|
||||
color: '#fa8c16',
|
||||
bg: 'rgba(250, 140, 22, 0.12)'
|
||||
},
|
||||
{
|
||||
key: '__documents_total',
|
||||
label: '文档总数',
|
||||
icon: FileTextOutlined,
|
||||
color: '#52c41a',
|
||||
bg: 'rgba(82, 196, 26, 0.12)'
|
||||
},
|
||||
{
|
||||
key: '__uncategorized',
|
||||
label: '未分类文档',
|
||||
icon: QuestionCircleOutlined,
|
||||
color: '#ff4d4f',
|
||||
bg: 'rgba(255, 77, 79, 0.12)'
|
||||
}
|
||||
{ key: 'doc_l1', label: 'L1 文档总结', icon: DatabaseOutlined, color: '#1677ff', bg: 'rgba(22, 119, 255, 0.12)' },
|
||||
{ key: 'doc_l2', label: 'L2 大纲节点', icon: ApartmentOutlined, color: '#722ed1', bg: 'rgba(114, 46, 209, 0.12)' },
|
||||
{ key: 'doc_l3', label: 'L3 内容大纲', icon: FileSearchOutlined, color: '#13c2c2', bg: 'rgba(19, 194, 194, 0.12)' },
|
||||
{ key: 'chunks', label: 'Chunks', icon: BlockOutlined, color: '#fa8c16', bg: 'rgba(250, 140, 22, 0.12)' },
|
||||
{ key: '__documents_total', label: '文档总数', icon: FileTextOutlined, color: '#52c41a', bg: 'rgba(82, 196, 26, 0.12)' },
|
||||
{ key: '__uncategorized', label: '未分类文档', icon: QuestionCircleOutlined, color: '#ff4d4f', bg: 'rgba(255, 77, 79, 0.12)' }
|
||||
]
|
||||
|
||||
const cards = computed(() => {
|
||||
const collections = statsData.value?.collections || {}
|
||||
return cardMeta.map((m) => {
|
||||
let value = 0
|
||||
if (m.key === '__documents_total') {
|
||||
value = statsData.value?.documents_total ?? 0
|
||||
} else if (m.key === '__uncategorized') {
|
||||
value = statsData.value?.uncategorized_count ?? 0
|
||||
} else {
|
||||
value = collections[m.key] ?? 0
|
||||
}
|
||||
if (m.key === '__documents_total') value = statsData.value?.documents_total ?? 0
|
||||
else if (m.key === '__uncategorized') value = statsData.value?.uncategorized_count ?? 0
|
||||
else value = collections[m.key] ?? 0
|
||||
return { ...m, value }
|
||||
})
|
||||
})
|
||||
@@ -126,26 +139,108 @@ onMounted(() => {
|
||||
<div class="overview page-section">
|
||||
<div class="overview__header">
|
||||
<h2 class="page-title">概览</h2>
|
||||
<a-space>
|
||||
<a-button :loading="isLoading" @click="loadStats">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-button :loading="isLoading" @click="loadStats">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 检索测试 -->
|
||||
<div class="overview__search">
|
||||
<h3 class="overview__subtitle">
|
||||
<SearchOutlined />
|
||||
<span>检索</span>
|
||||
</h3>
|
||||
<a-form
|
||||
ref="searchFormRef"
|
||||
:model="searchForm"
|
||||
:rules="searchRules"
|
||||
layout="inline"
|
||||
>
|
||||
<a-form-item name="query" style="flex: 1; min-width: 260px">
|
||||
<a-input
|
||||
v-model:value="searchForm.query"
|
||||
placeholder="请输入查询语句"
|
||||
allow-clear
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item name="top_k">
|
||||
<a-input-number
|
||||
v-model:value="searchForm.top_k"
|
||||
:min="1"
|
||||
:max="50"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item name="summarize">
|
||||
<a-checkbox v-model:checked="searchForm.summarize">AI 总结</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="isSearching" @click="handleSearch">
|
||||
检索
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div v-if="resultData" class="overview__result">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="routed_categories">
|
||||
{{ routedCategoriesText(resultData.routed_categories) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-alert
|
||||
v-if="resultData.fallback"
|
||||
class="overview__alert"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="fallback:本次检索触发了回退策略(路由类目无命中,已降级全局检索)"
|
||||
/>
|
||||
|
||||
<div v-if="resultData.summary" class="overview__summary">
|
||||
<div class="overview__summary-title">AI 总结</div>
|
||||
<div class="text-break">{{ resultData.summary }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="resultData.extracted_info" class="overview__extracted">
|
||||
<div class="overview__extracted-title">AI 提取的关键信息</div>
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="改写">{{ eiValue(resultData.extracted_info.rewrite) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="关键词">{{ eiValue(resultData.extracted_info.keywords) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="实体">{{ eiValue(resultData.extracted_info.entities) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="意图">{{ eiValue(resultData.extracted_info.intent) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="时间范围">{{ eiValue(resultData.extracted_info.time_range) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="命中类目">{{ eiValue(resultData.extracted_info.categories) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="text-muted" style="margin: 12px 0 8px">
|
||||
命中 {{ hits(resultData).length }} 条
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(hit, idx) in hits(resultData)"
|
||||
:key="`hit-${idx}`"
|
||||
class="overview__hit"
|
||||
>
|
||||
<div class="overview__hit-meta">
|
||||
score={{ hit.score }} | doc_id={{ hit.doc_id }} | 标题={{ hit.title || '' }} | section={{ hit.section_path || '' }}
|
||||
</div>
|
||||
<div class="overview__hit-snippet text-break">{{ hit.text || '' }}</div>
|
||||
<div v-if="hit.doc_summary" class="overview__hit-meta">
|
||||
文档摘要:{{ hit.doc_summary }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 概览统计 -->
|
||||
<a-spin :spinning="isLoading">
|
||||
<div class="overview__cards">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
class="overview__card"
|
||||
>
|
||||
<div v-for="card in cards" :key="card.key" class="overview__card">
|
||||
<div class="overview__card-body">
|
||||
<div
|
||||
class="overview__card-icon"
|
||||
:style="{ background: card.bg, color: card.color }"
|
||||
>
|
||||
<div class="overview__card-icon" :style="{ background: card.bg, color: card.color }">
|
||||
<component :is="card.icon" />
|
||||
</div>
|
||||
<div class="overview__card-info">
|
||||
@@ -166,9 +261,7 @@ onMounted(() => {
|
||||
共 {{ categoryBars.length }} 个类目
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="categoryBars.length === 0" class="overview__empty text-muted">
|
||||
暂无数据
|
||||
</div>
|
||||
<div v-if="categoryBars.length === 0" class="overview__empty text-muted">暂无数据</div>
|
||||
<div v-else class="overview__bars">
|
||||
<div
|
||||
v-for="(bar, idx) in categoryBars"
|
||||
@@ -200,6 +293,72 @@ onMounted(() => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.overview__search {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.overview__result {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.overview__alert {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.overview__summary {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.overview__summary-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.overview__extracted {
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.overview__extracted-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.overview__hit {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.overview__hit-meta {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 6px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.overview__hit-snippet {
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.overview__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
@@ -272,7 +431,7 @@ onMounted(() => {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
margin: 0 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -346,4 +505,4 @@ onMounted(() => {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,234 +0,0 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { search as searchApi } from '@/api/search'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const resultData = ref(null)
|
||||
|
||||
const formRef = ref(null)
|
||||
const formState = reactive({
|
||||
query: '',
|
||||
top_k: 5,
|
||||
summarize: false
|
||||
})
|
||||
|
||||
const rules = {
|
||||
query: [{ required: true, message: '请输入查询语句', trigger: 'blur' }],
|
||||
top_k: [{ type: 'number', min: 1, max: 50, message: 'top_k 范围 1~50', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const routedCategoriesText = (cats) => {
|
||||
if (!cats || !cats.length) return '(无)'
|
||||
return cats.join(', ')
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
resultData.value = null
|
||||
try {
|
||||
const payload = {
|
||||
query: formState.query,
|
||||
top_k: formState.top_k,
|
||||
summarize: formState.summarize
|
||||
}
|
||||
resultData.value = await searchApi(payload)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '检索失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function eiValue(value) {
|
||||
if (value === undefined || value === null || value === '') return '(无)'
|
||||
if (Array.isArray(value)) {
|
||||
return value.length ? value.join(', ') : '(无)'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const hits = (data) => data?.hits || []
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search page-section">
|
||||
<div class="search__header">
|
||||
<h2 class="search__title">检索测试台</h2>
|
||||
</div>
|
||||
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="查询语句" name="query">
|
||||
<a-input
|
||||
v-model:value="formState.query"
|
||||
placeholder="请输入查询语句"
|
||||
allow-clear
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="top_k" name="top_k">
|
||||
<a-input-number
|
||||
v-model:value="formState.top_k"
|
||||
:min="1"
|
||||
:max="50"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item name="summarize">
|
||||
<a-checkbox v-model:checked="formState.summarize">
|
||||
对结果生成 AI 总结
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="isLoading" @click="handleSearch">
|
||||
检索
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div v-if="resultData" class="search__result">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="routed_categories">
|
||||
{{ routedCategoriesText(resultData.routed_categories) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-alert
|
||||
v-if="resultData.fallback"
|
||||
class="search__alert"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="fallback:本次检索触发了回退策略(路由类目无命中,已降级全局检索)"
|
||||
/>
|
||||
|
||||
<div v-if="resultData.summary" class="search__summary">
|
||||
<div class="search__summary-title">AI 总结</div>
|
||||
<div class="text-break">{{ resultData.summary }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="resultData.extracted_info" class="search__extracted">
|
||||
<div class="search__extracted-title">AI 提取的关键信息</div>
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="改写">
|
||||
{{ eiValue(resultData.extracted_info.rewrite) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="关键词">
|
||||
{{ eiValue(resultData.extracted_info.keywords) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="实体">
|
||||
{{ eiValue(resultData.extracted_info.entities) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="意图">
|
||||
{{ eiValue(resultData.extracted_info.intent) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="时间范围">
|
||||
{{ eiValue(resultData.extracted_info.time_range) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="命中类目">
|
||||
{{ eiValue(resultData.extracted_info.categories) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="text-muted" style="margin: 12px 0 8px">
|
||||
命中 {{ hits(resultData).length }} 条
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(hit, idx) in hits(resultData)"
|
||||
:key="`hit-${idx}`"
|
||||
class="search__hit"
|
||||
>
|
||||
<div class="search__hit-meta">
|
||||
score={{ hit.score }} | doc_id={{ hit.doc_id }} | 标题={{ hit.title || '' }} | section={{ hit.section_path || '' }}
|
||||
</div>
|
||||
<div class="search__hit-snippet text-break">{{ hit.text || '' }}</div>
|
||||
<div v-if="hit.doc_summary" class="search__hit-meta">
|
||||
文档摘要:{{ hit.doc_summary }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search__title {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search__result {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.search__alert {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.search__summary {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search__summary-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.search__extracted {
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search__extracted-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.search__hit {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.search__hit-meta {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 6px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.search__hit-snippet {
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
@@ -1,179 +0,0 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { taskList } from '@/api/documents'
|
||||
import {
|
||||
INGEST_STATUS_TEXT,
|
||||
INGEST_STATUS_COLOR
|
||||
} from '@/constants/ingest'
|
||||
|
||||
const tasks = ref([])
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const isPolling = ref(false)
|
||||
const limit = ref(50)
|
||||
|
||||
let timer = null
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return '-'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function statusText(s) {
|
||||
return INGEST_STATUS_TEXT[s] || s || '-'
|
||||
}
|
||||
|
||||
function statusColor(s) {
|
||||
return INGEST_STATUS_COLOR[s] || 'default'
|
||||
}
|
||||
|
||||
async function loadTasks(showSpinner = false) {
|
||||
if (showSpinner) isLoading.value = true
|
||||
try {
|
||||
const data = await taskList(limit.value)
|
||||
tasks.value = data.items || []
|
||||
total.value = data.total ?? tasks.value.length
|
||||
} catch (err) {
|
||||
message.error(err?.message || '加载入库任务失败')
|
||||
} finally {
|
||||
if (showSpinner) isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (timer) return
|
||||
isPolling.value = true
|
||||
timer = setInterval(async () => {
|
||||
await loadTasks(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
isPolling.value = false
|
||||
}
|
||||
|
||||
function togglePolling() {
|
||||
if (isPolling.value) {
|
||||
stopPolling()
|
||||
} else {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
loadTasks(true)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTasks(true)
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'task_id', dataIndex: 'task_id', key: 'task_id', width: 260 },
|
||||
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 110 },
|
||||
{ title: 'doc_id', dataIndex: 'doc_id', key: 'doc_id', width: 260 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 170 }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tasks page-section">
|
||||
<div class="tasks__header">
|
||||
<h2 class="tasks__title">入库进度</h2>
|
||||
<div class="tasks__actions">
|
||||
<span class="text-muted" style="margin-right: 8px">共 {{ total }} 条</span>
|
||||
<a-button size="small" :loading="isLoading" @click="handleRefresh">刷新</a-button>
|
||||
<a-button size="small" :type="isPolling ? 'default' : 'primary'" @click="togglePolling">
|
||||
{{ isPolling ? '停止轮询' : '开始轮询' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="isPolling"
|
||||
class="tasks__alert"
|
||||
type="info"
|
||||
show-icon
|
||||
message="每 2 秒自动刷新,展示近期上传文件与摘要入库进度。"
|
||||
/>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tasks"
|
||||
:pagination="false"
|
||||
:loading="isLoading"
|
||||
row-key="task_id"
|
||||
size="small"
|
||||
:scroll="{ x: 1100 }"
|
||||
>
|
||||
<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 v-else-if="column.key === 'filename'">
|
||||
<span :title="record.filename">{{ record.filename || '-' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'doc_id'">
|
||||
<a-typography-text
|
||||
v-if="record.doc_id"
|
||||
copyable
|
||||
:style="{ fontSize: '12px' }"
|
||||
>
|
||||
{{ record.doc_id }}
|
||||
</a-typography-text>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'created_at'">
|
||||
{{ formatTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'updated_at'">
|
||||
{{ formatTime(record.updated_at) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tasks__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tasks__title {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tasks__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tasks__alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,394 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
ClockCircleOutlined,
|
||||
EyeOutlined,
|
||||
ArrowLeftOutlined,
|
||||
UndoOutlined,
|
||||
DeleteOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
taskList,
|
||||
taskStatus,
|
||||
retryTask,
|
||||
deleteTask
|
||||
} from '@/api/documents'
|
||||
import {
|
||||
INGEST_STATUS_TEXT,
|
||||
INGEST_STATUS_COLOR
|
||||
} from '@/constants/ingest'
|
||||
import { useTasksDrawer } from '@/composables/useTasksDrawer'
|
||||
import { formatSize } from '@/utils/format'
|
||||
|
||||
const { state, close } = useTasksDrawer()
|
||||
|
||||
const tasks = ref([])
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const isPolling = ref(false)
|
||||
|
||||
/* 选中任务详情 */
|
||||
const selectedTask = ref(null)
|
||||
const isLoadingDetail = ref(false)
|
||||
const isRetrying = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
let timer = null
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return '-'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function statusText(s) {
|
||||
return INGEST_STATUS_TEXT[s] || s || '-'
|
||||
}
|
||||
|
||||
function statusColor(s) {
|
||||
return INGEST_STATUS_COLOR[s] || 'default'
|
||||
}
|
||||
|
||||
const isTerminal = computed(() => ['done', 'failed'].includes(selectedTask.value?.status))
|
||||
const detailResult = computed(() => selectedTask.value?.result || null)
|
||||
const detailError = computed(() => selectedTask.value?.error || null)
|
||||
const detailSummary = computed(() => detailResult.value?.summary || {})
|
||||
const canRetry = computed(() => ['failed', 'done'].includes(selectedTask.value?.status))
|
||||
const canDelete = computed(() => ['failed', 'done'].includes(selectedTask.value?.status))
|
||||
|
||||
/* 从任务记录提取详情展示字段(title/source/size) */
|
||||
const detailComputed = computed(() => {
|
||||
const task = selectedTask.value
|
||||
if (!task) return null
|
||||
const source = task.source || {}
|
||||
const metadata = source.metadata || {}
|
||||
return {
|
||||
title: task.title || source.title || '-',
|
||||
source: task.source_text || source.source || '-',
|
||||
size: task.size_bytes != null ? formatSize(task.size_bytes) : formatSize(metadata.original_size_bytes)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadTasks(showSpinner = false) {
|
||||
if (showSpinner) isLoading.value = true
|
||||
try {
|
||||
const data = await taskList(50)
|
||||
tasks.value = data.items || []
|
||||
total.value = data.total ?? tasks.value.length
|
||||
} catch (err) {
|
||||
message.error(err?.message || '加载入库任务失败')
|
||||
} finally {
|
||||
if (showSpinner) isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (timer) return
|
||||
isPolling.value = true
|
||||
timer = setInterval(async () => {
|
||||
await loadTasks(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
isPolling.value = false
|
||||
}
|
||||
|
||||
function togglePolling() {
|
||||
if (isPolling.value) stopPolling()
|
||||
else startPolling()
|
||||
}
|
||||
|
||||
/* 打开/关闭抽屉时启停轮询 */
|
||||
watch(
|
||||
() => state.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
loadTasks(true)
|
||||
startPolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
selectedTask.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function openDetail(task) {
|
||||
selectedTask.value = null
|
||||
isLoadingDetail.value = true
|
||||
try {
|
||||
selectedTask.value = await taskStatus(task.task_id)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '加载任务详情失败')
|
||||
} finally {
|
||||
isLoadingDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
selectedTask.value = null
|
||||
}
|
||||
|
||||
async function handleRetry(task) {
|
||||
const taskId = task?.task_id || selectedTask.value?.task_id
|
||||
if (!taskId) return
|
||||
isRetrying.value = true
|
||||
try {
|
||||
const data = await retryTask(taskId)
|
||||
message.success(`已提交新任务:${data.task_id}`)
|
||||
selectedTask.value = null
|
||||
await loadTasks(false)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '重试失败')
|
||||
} finally {
|
||||
isRetrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(task) {
|
||||
const taskId = task?.task_id || selectedTask.value?.task_id
|
||||
if (!taskId) return
|
||||
Modal.confirm({
|
||||
title: '删除入库任务',
|
||||
content: `确定删除任务「${taskId}」吗?删除后不可恢复。`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await deleteTask(taskId)
|
||||
message.success('任务已删除')
|
||||
selectedTask.value = null
|
||||
await loadTasks(false)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '删除任务失败')
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeUnmount(stopPolling)
|
||||
|
||||
const columns = [
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 90 },
|
||||
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
|
||||
{ title: 'task_id', dataIndex: 'task_id', key: 'task_id', width: 200 },
|
||||
{ title: 'doc_id', dataIndex: 'doc_id', key: 'doc_id', width: 200 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 130 }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-drawer
|
||||
:open="state.open"
|
||||
:title="selectedTask ? '任务详情' : '入库进度'"
|
||||
placement="right"
|
||||
width="640"
|
||||
:destroy-on-close="true"
|
||||
@close="close"
|
||||
>
|
||||
<!-- 列表视图 -->
|
||||
<template v-if="!selectedTask">
|
||||
<div class="tasks-drawer__header">
|
||||
<span class="text-muted">共 {{ total }} 条</span>
|
||||
<div class="tasks-drawer__actions">
|
||||
<a-button size="small" :loading="isLoading" @click="loadTasks(true)">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button size="small" :type="isPolling ? 'default' : 'primary'" @click="togglePolling">
|
||||
{{ isPolling ? '停止轮询' : '开始轮询' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="isPolling"
|
||||
type="info"
|
||||
show-icon
|
||||
message="每 2 秒自动刷新近期入库任务。"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tasks"
|
||||
:pagination="false"
|
||||
:loading="isLoading"
|
||||
row-key="task_id"
|
||||
size="small"
|
||||
:scroll="{ x: 720 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'filename'">
|
||||
<span :title="record.filename">{{ record.filename || '-' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'task_id'">
|
||||
<a-typography-text copyable :style="{ fontSize: '12px' }">{{ record.task_id }}</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'doc_id'">
|
||||
<a-typography-text
|
||||
v-if="record.doc_id"
|
||||
copyable
|
||||
:style="{ fontSize: '12px' }"
|
||||
>
|
||||
{{ record.doc_id }}
|
||||
</a-typography-text>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'created_at'">
|
||||
{{ formatTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openDetail(record)">
|
||||
<template #icon><EyeOutlined /></template>
|
||||
详情
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="['failed', 'done'].includes(record.status)"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleRetry(record)"
|
||||
>
|
||||
<template #icon><UndoOutlined /></template>
|
||||
重试
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="['done', 'failed'].includes(record.status)"
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
@click="handleDelete(record)"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<!-- 详情视图 -->
|
||||
<template v-else>
|
||||
<a-button size="small" type="link" style="margin-bottom: 12px; padding-left: 0" @click="backToList">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回列表
|
||||
</a-button>
|
||||
|
||||
<a-spin :spinning="isLoadingDetail">
|
||||
<template v-if="selectedTask">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="statusColor(selectedTask.status)">{{ statusText(selectedTask.status) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="task_id">{{ selectedTask.task_id || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="文件名">{{ selectedTask.filename || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="标题">{{ detailComputed?.title || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="来源">{{ detailComputed?.source || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="文件大小">{{ detailComputed?.size || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="doc_id">{{ selectedTask.doc_id || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ formatTime(selectedTask.created_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">{{ formatTime(selectedTask.updated_at) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 完成详情 -->
|
||||
<div v-if="isTerminal && selectedTask.status === 'done' && detailResult" class="tasks-drawer__block">
|
||||
<h4 class="tasks-drawer__block-title">入库结果</h4>
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="document_id">{{ detailResult.document_id || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="类目">{{ detailResult.category || '-' }}(置信度 {{ detailResult.category_confidence ?? '-' }})</a-descriptions-item>
|
||||
<a-descriptions-item label="标签">
|
||||
<template v-if="detailResult.tags && detailResult.tags.length">
|
||||
<a-tag v-for="tag in detailResult.tags" :key="tag">{{ tag }}</a-tag>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="总结层级">{{ detailSummary.level ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="chunks_count">{{ detailResult.chunks_count ?? 0 }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailResult.deduplicated" label="去重">是(复用既有文档)</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<h4 class="tasks-drawer__block-title">L1 总结</h4>
|
||||
<pre class="tasks-drawer__pre">{{ detailSummary.l1_summary || '' }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 失败详情 -->
|
||||
<div v-if="isTerminal && selectedTask.status === 'failed' && detailError" class="tasks-drawer__block">
|
||||
<a-alert
|
||||
type="error"
|
||||
show-icon
|
||||
:message="`失败阶段:${detailError.stage || '-'}`"
|
||||
:description="detailError.message || '-'"
|
||||
/>
|
||||
<template v-if="detailError.partial_summary && detailError.partial_summary.l1_summary">
|
||||
<h4 class="tasks-drawer__block-title">L1 总结(部分)</h4>
|
||||
<pre class="tasks-drawer__pre">{{ detailError.partial_summary.l1_summary }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="tasks-drawer__actions">
|
||||
<a-button v-if="canRetry" :loading="isRetrying" @click="handleRetry(selectedTask)">
|
||||
<template #icon><UndoOutlined /></template>
|
||||
重试
|
||||
</a-button>
|
||||
<a-button v-if="canDelete" danger :loading="isDeleting" @click="handleDelete(selectedTask)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-spin>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tasks-drawer__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tasks-drawer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tasks-drawer__block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tasks-drawer__block-title {
|
||||
font-size: 13px;
|
||||
margin: 12px 0 6px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.tasks-drawer__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: 220px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user