feat: 前端页面优化与入库任务重试/删除

- 概览与检索合并为单一概览页
- 类目/文档管理/入库整合为树形目录知识库页(Library),支持文档搜索
- 入库进度改为右侧 Drawer,支持任务详情、重试、删除
- 后端新增任务重试与删除接口
This commit is contained in:
2026-08-04 12:58:11 +08:00
parent f0fc20a9b6
commit 35b7decd49
16 changed files with 1630 additions and 1432 deletions
+22
View File
@@ -335,6 +335,28 @@ async def get_ingest_task(task_id: str) -> dict[str, Any]:
return ok(task) return ok(task)
@router.post("/documents/tasks/{task_id}/retry")
async def retry_ingest_task(
task_id: str, user: UserRecord = Depends(get_current_user)
) -> JSONResponse:
"""重试入库任务:用原 source 重新提交一个新任务,返回新 task_id"""
new_task_id = await _get_task_manager().retry(task_id)
if new_task_id is None:
raise ApiError(1004, "任务不存在或缺少重试所需的源信息")
return JSONResponse(
status_code=202, content=ok({"task_id": new_task_id, "status": "pending"})
)
@router.delete("/documents/tasks/{task_id}")
async def delete_ingest_task(
task_id: str, user: UserRecord = Depends(get_current_user)
) -> dict[str, Any]:
"""删除入库任务(内存注册表 + Redis 镜像),幂等"""
deleted = await _get_task_manager().delete(task_id)
return ok({"task_id": task_id, "deleted": deleted})
@router.get("/documents") @router.get("/documents")
async def list_documents( async def list_documents(
limit: int = Query(default=20, ge=1, le=100), limit: int = Query(default=20, ge=1, le=100),
+50
View File
@@ -80,6 +80,7 @@ class IngestTaskManager:
task_id = uuid.uuid4().hex task_id = uuid.uuid4().hex
now = _utc_now_iso() now = _utc_now_iso()
filename = self._extract_filename(doc) filename = self._extract_filename(doc)
source = self._extract_source(doc)
dedup = get_dedup_strategy(self._redis) dedup = get_dedup_strategy(self._redis)
# 1. 去重命中:直接置 done,复用旧结果,不调 _run # 1. 去重命中:直接置 done,复用旧结果,不调 _run
@@ -95,6 +96,7 @@ class IngestTaskManager:
"result": result_dict, "result": result_dict,
"error": None, "error": None,
"filename": filename, "filename": filename,
"source": source,
} }
self._schedule_mirror(task_id) self._schedule_mirror(task_id)
logger.info( logger.info(
@@ -113,6 +115,7 @@ class IngestTaskManager:
"result": None, "result": None,
"error": None, "error": None,
"filename": filename, "filename": filename,
"source": source,
} }
self._schedule_mirror(task_id) self._schedule_mirror(task_id)
background = asyncio.create_task(self._run(task_id, doc, dedup)) background = asyncio.create_task(self._run(task_id, doc, dedup))
@@ -190,15 +193,62 @@ class IngestTaskManager:
"""从文档输入提取展示用文件名:优先 metadata.original_filename,其次 title""" """从文档输入提取展示用文件名:优先 metadata.original_filename,其次 title"""
return doc.metadata.get("original_filename") or doc.title or None return doc.metadata.get("original_filename") or doc.title or None
@staticmethod
def _extract_source(doc: DocumentInput) -> dict[str, Any]:
"""从文档输入提取重试所需的源信息(供 retry 复用,含文件路径/大小)"""
return {
"text": doc.text,
"title": doc.title,
"source": doc.source,
"metadata": dict(doc.metadata),
}
async def retry(self, task_id: str) -> str | None:
"""重试失败/已完成的入库任务:用原 source 重新提交一个新任务
返回新 task_id;任务不存在或缺少 source 时返回 None。
"""
record = await self.get(task_id)
if record is None:
return None
source = record.get("source")
if not isinstance(source, dict) or not source.get("text"):
return None
doc = DocumentInput(
text=source["text"],
title=source.get("title") or "",
source=source.get("source") or "",
metadata=source.get("metadata") or {},
)
return await self.submit(doc)
async def delete(self, task_id: str) -> bool:
"""删除入库任务(内存注册表 + Redis 镜像),不存在返回 False"""
existed = self._tasks.pop(task_id, None) is not None
if self._redis is not None:
try:
get_client = getattr(self._redis, "_get_client", None)
if callable(get_client):
await get_client().delete(f"{REDIS_KEY_PREFIX}{task_id}")
existed = True
except Exception:
logger.warning("删除 Redis 任务镜像失败", task_id=task_id, exc_info=True)
return existed
@staticmethod @staticmethod
def _to_list_item(record: dict[str, Any]) -> dict[str, Any]: def _to_list_item(record: dict[str, Any]) -> dict[str, Any]:
"""从完整任务记录提取列表展示字段""" """从完整任务记录提取列表展示字段"""
result = record.get("result") result = record.get("result")
doc_id = result.get("document_id") if isinstance(result, dict) else None doc_id = result.get("document_id") if isinstance(result, dict) else None
source = record.get("source") or {}
metadata = source.get("metadata") or {}
return { return {
"task_id": record.get("task_id"), "task_id": record.get("task_id"),
"status": record.get("status"), "status": record.get("status"),
"filename": record.get("filename"), "filename": record.get("filename"),
"title": source.get("title"),
"source": source.get("source"),
"size_bytes": metadata.get("original_size_bytes"),
"created_at": record.get("created_at"), "created_at": record.get("created_at"),
"updated_at": record.get("updated_at"), "updated_at": record.get("updated_at"),
"doc_id": doc_id, "doc_id": doc_id,
+18
View File
@@ -83,6 +83,24 @@ export function taskList(limit = 20) {
return http.get('/api/v1/documents/tasks', { params: { limit } }) 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 * @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
}
}
}
+12 -8
View File
@@ -5,9 +5,6 @@ import { storeToRefs } from 'pinia'
import { Modal, message } from 'ant-design-vue' import { Modal, message } from 'ant-design-vue'
import { import {
DashboardOutlined, DashboardOutlined,
FileTextOutlined,
UploadOutlined,
SearchOutlined,
AppstoreOutlined, AppstoreOutlined,
SettingOutlined, SettingOutlined,
MenuFoldOutlined, MenuFoldOutlined,
@@ -21,11 +18,14 @@ import {
import { useAuthStore } from '@/stores/useAuthStore' import { useAuthStore } from '@/stores/useAuthStore'
import { changeMyPassword } from '@/api/auth' import { changeMyPassword } from '@/api/auth'
import { callApi } from '@/api/client' import { callApi } from '@/api/client'
import TasksDrawer from '@/views/TasksDrawer.vue'
import { useTasksDrawer } from '@/composables/useTasksDrawer'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const { user, displayName } = storeToRefs(authStore) const { user, displayName } = storeToRefs(authStore)
const tasksDrawer = useTasksDrawer()
const collapsed = ref(false) const collapsed = ref(false)
@@ -37,12 +37,9 @@ const openKeys = ref(['main'])
const menuItems = computed(() => { const menuItems = computed(() => {
const items = [ const items = [
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览' }, { key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' },
{ key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' }, { key: 'library', icon: () => h(AppstoreOutlined), label: '知识库' },
{ key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' },
{ key: 'tasks', icon: () => h(ClockCircleOutlined), 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: 'settings', icon: () => h(SettingOutlined), label: '设置' },
{ key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' } { key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' }
] ]
@@ -53,6 +50,10 @@ const menuItems = computed(() => {
}) })
function handleMenuClick({ key }) { function handleMenuClick({ key }) {
if (key === 'tasks') {
tasksDrawer.open()
return
}
if (key && key !== route.name) { if (key && key !== route.name) {
router.push({ name: key }) router.push({ name: key })
} }
@@ -194,6 +195,9 @@ async function handleSubmitPassword() {
</a-layout-footer> </a-layout-footer>
</a-layout> </a-layout>
<!-- 入库进度右侧 Drawer -->
<TasksDrawer />
<!-- 强制改密弹窗must_change_password 用户不可关闭 --> <!-- 强制改密弹窗must_change_password 用户不可关闭 -->
<a-modal <a-modal
v-model:open="passwordModalVisible" v-model:open="passwordModalVisible"
+5 -29
View File
@@ -18,37 +18,13 @@ const routes = [
path: 'overview', path: 'overview',
name: 'overview', name: 'overview',
component: () => import('@/views/Overview.vue'), component: () => import('@/views/Overview.vue'),
meta: { title: '概览', requiresAuth: true } meta: { title: '概览与检索', requiresAuth: true }
}, },
{ {
path: 'documents', path: 'library',
name: 'documents', name: 'library',
component: () => import('@/views/Documents.vue'), component: () => import('@/views/Library.vue'),
meta: { title: '文档管理', requiresAuth: true } 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: 'settings', path: 'settings',
+14
View File
@@ -10,6 +10,20 @@ export function truncate(text, maxLen = 80) {
return s.length > maxLen ? `${s.slice(0, maxLen)}` : s 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 * @param {...(string | false | null | undefined)} args
-70
View File
@@ -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>
-346
View File
@@ -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>
-495
View File
@@ -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>
+815
View File
@@ -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>
+229 -70
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { import {
DatabaseOutlined, DatabaseOutlined,
@@ -9,69 +9,82 @@ import {
FileTextOutlined, FileTextOutlined,
QuestionCircleOutlined, QuestionCircleOutlined,
ReloadOutlined, ReloadOutlined,
AppstoreOutlined AppstoreOutlined,
SearchOutlined
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { stats as fetchStats } from '@/api/knowledge' 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 isLoading = ref(false)
const statsData = ref(null) const statsData = ref(null)
const cardMeta = [ const cardMeta = [
{ { key: 'doc_l1', label: 'L1 文档总结', icon: DatabaseOutlined, color: '#1677ff', bg: 'rgba(22, 119, 255, 0.12)' },
key: 'doc_l1', { key: 'doc_l2', label: 'L2 大纲节点', icon: ApartmentOutlined, color: '#722ed1', bg: 'rgba(114, 46, 209, 0.12)' },
label: 'L1 文档总结', { key: 'doc_l3', label: 'L3 内容大纲', icon: FileSearchOutlined, color: '#13c2c2', bg: 'rgba(19, 194, 194, 0.12)' },
icon: DatabaseOutlined, { key: 'chunks', label: 'Chunks', icon: BlockOutlined, color: '#fa8c16', bg: 'rgba(250, 140, 22, 0.12)' },
color: '#1677ff', { key: '__documents_total', label: '文档总数', icon: FileTextOutlined, color: '#52c41a', bg: 'rgba(82, 196, 26, 0.12)' },
bg: 'rgba(22, 119, 255, 0.12)' { key: '__uncategorized', label: '未分类文档', icon: QuestionCircleOutlined, color: '#ff4d4f', bg: 'rgba(255, 77, 79, 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 cards = computed(() => {
const collections = statsData.value?.collections || {} const collections = statsData.value?.collections || {}
return cardMeta.map((m) => { return cardMeta.map((m) => {
let value = 0 let value = 0
if (m.key === '__documents_total') { if (m.key === '__documents_total') value = statsData.value?.documents_total ?? 0
value = statsData.value?.documents_total ?? 0 else if (m.key === '__uncategorized') value = statsData.value?.uncategorized_count ?? 0
} else if (m.key === '__uncategorized') { else value = collections[m.key] ?? 0
value = statsData.value?.uncategorized_count ?? 0
} else {
value = collections[m.key] ?? 0
}
return { ...m, value } return { ...m, value }
}) })
}) })
@@ -126,26 +139,108 @@ onMounted(() => {
<div class="overview page-section"> <div class="overview page-section">
<div class="overview__header"> <div class="overview__header">
<h2 class="page-title">概览</h2> <h2 class="page-title">概览</h2>
<a-space> <a-button :loading="isLoading" @click="loadStats">
<a-button :loading="isLoading" @click="loadStats"> <template #icon><ReloadOutlined /></template>
<template #icon><ReloadOutlined /></template> 刷新
刷新 </a-button>
</a-button>
</a-space>
</div> </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"> <a-spin :spinning="isLoading">
<div class="overview__cards"> <div class="overview__cards">
<div <div v-for="card in cards" :key="card.key" class="overview__card">
v-for="card in cards"
:key="card.key"
class="overview__card"
>
<div class="overview__card-body"> <div class="overview__card-body">
<div <div class="overview__card-icon" :style="{ background: card.bg, color: card.color }">
class="overview__card-icon"
:style="{ background: card.bg, color: card.color }"
>
<component :is="card.icon" /> <component :is="card.icon" />
</div> </div>
<div class="overview__card-info"> <div class="overview__card-info">
@@ -166,9 +261,7 @@ onMounted(() => {
{{ categoryBars.length }} 个类目 {{ categoryBars.length }} 个类目
</span> </span>
</div> </div>
<div v-if="categoryBars.length === 0" class="overview__empty text-muted"> <div v-if="categoryBars.length === 0" class="overview__empty text-muted">暂无数据</div>
暂无数据
</div>
<div v-else class="overview__bars"> <div v-else class="overview__bars">
<div <div
v-for="(bar, idx) in categoryBars" v-for="(bar, idx) in categoryBars"
@@ -200,6 +293,72 @@ onMounted(() => {
margin-bottom: 16px; 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 { .overview__cards {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -272,7 +431,7 @@ onMounted(() => {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #374151; color: #374151;
margin: 0; margin: 0 0 12px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
-234
View File
@@ -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>
-179
View File
@@ -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>
+394
View File
@@ -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>
+49
View File
@@ -19,6 +19,8 @@ class FakeManager:
self.tasks = tasks or {} self.tasks = tasks or {}
self.task_id = task_id self.task_id = task_id
self.submitted: list[DocumentInput] = [] self.submitted: list[DocumentInput] = []
self.retry_result: str | None = None
self.delete_result: bool = False
async def submit(self, doc: DocumentInput) -> str: async def submit(self, doc: DocumentInput) -> str:
self.submitted.append(doc) self.submitted.append(doc)
@@ -27,6 +29,12 @@ class FakeManager:
async def get(self, task_id: str) -> dict[str, Any] | None: async def get(self, task_id: str) -> dict[str, Any] | None:
return self.tasks.get(task_id) return self.tasks.get(task_id)
async def retry(self, task_id: str) -> str | None:
return self.retry_result
async def delete(self, task_id: str) -> bool:
return self.delete_result
def _task(task_id: str, status: str, **extra: Any) -> dict[str, Any]: def _task(task_id: str, status: str, **extra: Any) -> dict[str, Any]:
"""构造一条任务记录(时间字段为固定 ISO8601 字符串)""" """构造一条任务记录(时间字段为固定 ISO8601 字符串)"""
@@ -159,3 +167,44 @@ def test_post_empty_text_creates_no_task(client: TestClient, monkeypatch: pytest
body = resp.json() body = resp.json()
assert body["code"] == 1001 assert body["code"] == 1001
assert manager.submitted == [] assert manager.submitted == []
def test_retry_task_returns_202(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""POST tasks/{id}/retryHTTP 202,返回新 task_id 与 pending 状态"""
manager = FakeManager()
manager.retry_result = "new-task-7"
_inject_manager(monkeypatch, manager)
resp = client.post("/api/v1/documents/tasks/t-failed/retry")
assert resp.status_code == 202
body = resp.json()
assert body["code"] == 0
assert body["data"]["task_id"] == "new-task-7"
assert body["data"]["status"] == "pending"
def test_retry_task_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""POST tasks/{id}/retrymanager 返回 None → code=1004"""
manager = FakeManager()
manager.retry_result = None
_inject_manager(monkeypatch, manager)
resp = client.post("/api/v1/documents/tasks/nope/retry")
body = resp.json()
assert body["code"] == 1004
def test_delete_task(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""DELETE tasks/{id}:删除成功 → deleted=true"""
manager = FakeManager()
manager.delete_result = True
_inject_manager(monkeypatch, manager)
resp = client.delete("/api/v1/documents/tasks/t-x")
body = resp.json()
assert body["code"] == 0
assert body["data"]["task_id"] == "t-x"
assert body["data"]["deleted"] is True