feat: 前端页面优化与入库任务重试/删除
- 概览与检索合并为单一概览页 - 类目/文档管理/入库整合为树形目录知识库页(Library),支持文档搜索 - 入库进度改为右侧 Drawer,支持任务详情、重试、删除 - 后端新增任务重试与删除接口
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
+229
-70
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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}/retry:HTTP 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}/retry:manager 返回 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
|
||||||
|
|||||||
Reference in New Issue
Block a user