feat: 入库改为独立页面,更新 API 说明文档与 Agent skill
- 新增 Ingest.vue 独立入库页(文本/文件/批量 + 任务进度列表),移除入库 Drawer - 导航栏去掉「入库进度」,新增「文档入库」入口;知识库页「入库」跳转独立页 - API 说明页接口清单与后端对齐(补齐用户管理/上传批量/任务重试删除/reingest 等,修正鉴权标注) - 更新 qmdsearch-agent skill 与 api-examples(鉴权说明、会话 token、新端点示例)
This commit is contained in:
@@ -13,19 +13,16 @@ import {
|
||||
DatabaseOutlined,
|
||||
ApiOutlined,
|
||||
TeamOutlined,
|
||||
ClockCircleOutlined
|
||||
UploadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { changeMyPassword } from '@/api/auth'
|
||||
import { callApi } from '@/api/client'
|
||||
import TasksDrawer from '@/views/TasksDrawer.vue'
|
||||
import { useTasksDrawer } from '@/composables/useTasksDrawer'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { user, displayName } = storeToRefs(authStore)
|
||||
const tasksDrawer = useTasksDrawer()
|
||||
|
||||
const collapsed = ref(false)
|
||||
|
||||
@@ -39,7 +36,7 @@ const menuItems = computed(() => {
|
||||
const items = [
|
||||
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' },
|
||||
{ key: 'library', icon: () => h(AppstoreOutlined), label: '知识库' },
|
||||
{ key: 'tasks', icon: () => h(ClockCircleOutlined), label: '入库进度' },
|
||||
{ key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' },
|
||||
{ key: 'settings', icon: () => h(SettingOutlined), label: '设置' },
|
||||
{ key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' }
|
||||
]
|
||||
@@ -50,10 +47,6 @@ const menuItems = computed(() => {
|
||||
})
|
||||
|
||||
function handleMenuClick({ key }) {
|
||||
if (key === 'tasks') {
|
||||
tasksDrawer.open()
|
||||
return
|
||||
}
|
||||
if (key && key !== route.name) {
|
||||
router.push({ name: key })
|
||||
}
|
||||
@@ -195,9 +188,6 @@ async function handleSubmitPassword() {
|
||||
</a-layout-footer>
|
||||
</a-layout>
|
||||
|
||||
<!-- 入库进度右侧 Drawer -->
|
||||
<TasksDrawer />
|
||||
|
||||
<!-- 强制改密弹窗(must_change_password 用户,不可关闭) -->
|
||||
<a-modal
|
||||
v-model:open="passwordModalVisible"
|
||||
|
||||
@@ -26,6 +26,12 @@ const routes = [
|
||||
component: () => import('@/views/Library.vue'),
|
||||
meta: { title: '知识库', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'ingest',
|
||||
name: 'ingest',
|
||||
component: () => import('@/views/Ingest.vue'),
|
||||
meta: { title: '文档入库', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
|
||||
@@ -2,21 +2,33 @@
|
||||
import { computed } from 'vue'
|
||||
import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined, DownloadOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 以下内容整理自项目 README.md 的「API 文档」章节
|
||||
// 以下内容与后端 app/api/v1 的路由实现保持一致
|
||||
const apiList = [
|
||||
{ method: 'GET', path: '/api/v1/health', desc: '健康检查', auth: false },
|
||||
{ method: 'POST', path: '/api/v1/auth/register', desc: '用户注册(可关闭)', auth: false },
|
||||
{ method: 'POST', path: '/api/v1/auth/login', desc: '用户登录,返回 JWT token', auth: false },
|
||||
{ method: 'GET', path: '/api/v1/auth/me', desc: '获取当前用户信息', auth: true },
|
||||
{ method: 'POST', path: '/api/v1/search', desc: '分层检索', auth: true },
|
||||
{ method: 'POST', path: '/api/v1/auth/login', desc: '用户登录,签发 session token(TTL 12h)', auth: false },
|
||||
{ method: 'POST', path: '/api/v1/auth/logout', desc: '退出登录(删除当前会话)', auth: true },
|
||||
{ method: 'POST', path: '/api/v1/auth/password', desc: '修改自己的密码', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/auth/me', desc: '当前登录用户信息(脱敏)', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/auth/users', desc: '用户列表(脱敏)', auth: 'admin' },
|
||||
{ method: 'POST', path: '/api/v1/auth/users', desc: '创建用户', auth: 'admin' },
|
||||
{ method: 'PATCH', path: '/api/v1/auth/users/{username}', desc: '更新用户角色 / 启用状态', auth: 'admin' },
|
||||
{ method: 'POST', path: '/api/v1/auth/users/{username}/password', desc: '重置指定用户密码', auth: 'admin' },
|
||||
{ method: 'DELETE', path: '/api/v1/auth/users/{username}', desc: '删除用户', auth: 'admin' },
|
||||
{ method: 'POST', path: '/api/v1/search', desc: '分层检索(L1→L2→L3→chunk)', auth: false },
|
||||
{ method: 'POST', path: '/api/v1/documents', desc: '文档入库(JSON 文本,202 异步入库)', auth: true },
|
||||
{ method: 'POST', path: '/api/v1/documents/upload', desc: '文件上传入库(multipart,202 异步)', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/documents/tasks/{task_id}', desc: '入库任务状态查询', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/documents', desc: '文档列表(分页)', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/documents/{doc_id}', desc: '文档详情', auth: true },
|
||||
{ method: 'POST', path: '/api/v1/documents/upload-batch', desc: '批量文件上传入库(多文件,202 异步)', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/documents/tasks', desc: '入库任务列表(按时间降序)', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/documents/tasks/{task_id}', desc: '入库任务状态查询', auth: false },
|
||||
{ method: 'POST', path: '/api/v1/documents/tasks/{task_id}/retry', desc: '重试入库任务(返回新 task_id)', auth: true },
|
||||
{ method: 'DELETE', path: '/api/v1/documents/tasks/{task_id}', desc: '删除入库任务(幂等)', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/documents', desc: '文档列表(limit/offset 分页)', auth: false },
|
||||
{ method: 'GET', path: '/api/v1/documents/{doc_id}', desc: '文档详情', auth: false },
|
||||
{ method: 'GET', path: '/api/v1/documents/{doc_id}/file', desc: '下载关联的原始文件', auth: false },
|
||||
{ method: 'POST', path: '/api/v1/documents/{doc_id}/reingest', desc: '重新摘要入库(读原文件→删旧→重跑)', auth: true },
|
||||
{ method: 'DELETE', path: '/api/v1/documents/{doc_id}', desc: '删除文档(幂等)', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/knowledge/categories', desc: '知识分类类目集', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/knowledge/categories', desc: '知识分类类目集', auth: false },
|
||||
{ method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: false },
|
||||
{ method: 'GET', path: '/admin', desc: '管理后台(本页)', auth: false }
|
||||
]
|
||||
|
||||
@@ -24,7 +36,18 @@ const methodColor = {
|
||||
GET: 'green',
|
||||
POST: 'blue',
|
||||
DELETE: 'red',
|
||||
PUT: 'orange'
|
||||
PUT: 'orange',
|
||||
PATCH: 'purple'
|
||||
}
|
||||
|
||||
function authLabel(auth) {
|
||||
if (auth === 'admin') return '仅 admin'
|
||||
return auth ? '需鉴权' : '否'
|
||||
}
|
||||
|
||||
function authColor(auth) {
|
||||
if (auth === 'admin') return 'gold'
|
||||
return auth ? 'volcano' : 'default'
|
||||
}
|
||||
|
||||
const baseUrl = computed(() => `${window.location.origin}/admin/`.replace(/\/admin\/$/, ''))
|
||||
@@ -33,16 +56,17 @@ const agentSkillUrl = computed(() => `${window.location.origin}/agent-skill`)
|
||||
|
||||
const loginExample = `curl -X POST ${baseUrl.value}/api/v1/auth/login \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"username": "admin", "password": "your-password"}'`
|
||||
-d '{"username": "admin", "password": "your-password"}'
|
||||
# → {"code":0,"data":{"token":"<session-token>","username":"admin","role":"admin","must_change_password":false},"message":"ok"}`
|
||||
|
||||
const searchExample = `curl -X POST ${baseUrl.value}/api/v1/search \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer <token>" \\
|
||||
-d '{"query": "如何配置 Redis 缓存", "top_k": 5}'`
|
||||
-d '{"query": "如何配置 Redis 缓存", "top_k": 5, "summarize": false}'`
|
||||
|
||||
const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
||||
-H "Authorization: Bearer <token>" \\
|
||||
-F "file=@document.pdf"`
|
||||
-F "file=@document.pdf" \\
|
||||
-F "source=manual"`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,7 +82,7 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
||||
class="apidocs__tip"
|
||||
type="info"
|
||||
show-icon
|
||||
message="需要鉴权的接口请在请求头携带 Authorization: Bearer <token>,token 通过 /auth/login 获取。"
|
||||
message="需要鉴权的接口请在请求头携带 Authorization: Bearer <token>,token 通过 /auth/login 获取;用户管理类接口(/auth/users*)仅 admin 角色可用。查询类接口(检索 / 文档列表 / 详情 / 下载 / 类目 / 统计 / 任务状态)免登录。"
|
||||
/>
|
||||
|
||||
<!-- AI Agent Skill 下载 -->
|
||||
@@ -117,8 +141,8 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
||||
<code class="api-path">{{ text }}</code>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'auth'">
|
||||
<a-tag :color="record.auth ? 'volcano' : 'default'">
|
||||
{{ record.auth ? '需鉴权' : '否' }}
|
||||
<a-tag :color="authColor(record.auth)">
|
||||
{{ authLabel(record.auth) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
@@ -130,7 +154,7 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
||||
<h3 class="section-subtitle">
|
||||
<SafetyCertificateOutlined /> 获取 Token
|
||||
</h3>
|
||||
<p class="apidocs__p">先调用登录接口获取 JWT,再在后续请求的 Header 中携带:</p>
|
||||
<p class="apidocs__p">先调用登录接口获取 session token,再在后续需要鉴权的请求 Header 中携带:</p>
|
||||
<pre class="code-block">{{ loginExample }}</pre>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
UploadOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
ArrowLeftOutlined,
|
||||
UndoOutlined,
|
||||
DeleteOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
ingest as ingestDocument,
|
||||
upload as uploadDocument,
|
||||
uploadBatch,
|
||||
taskList,
|
||||
taskStatus,
|
||||
retryTask,
|
||||
deleteTask
|
||||
} from '@/api/documents'
|
||||
import { useIngestPolling } from '@/composables/useIngestPolling'
|
||||
import { INGEST_STATUS_TEXT, INGEST_STATUS_COLOR } from '@/constants/ingest'
|
||||
import { formatSize } from '@/utils/format'
|
||||
|
||||
/* ---------- 入库表单 ---------- */
|
||||
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 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)
|
||||
await loadTasks(false)
|
||||
} 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)
|
||||
await loadTasks(false)
|
||||
} 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)
|
||||
}
|
||||
await loadTasks(false)
|
||||
} 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' }
|
||||
]
|
||||
|
||||
/* ---------- 入库任务列表 ---------- */
|
||||
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'
|
||||
}
|
||||
|
||||
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 startListPolling() {
|
||||
if (timer) return
|
||||
isPolling.value = true
|
||||
timer = setInterval(async () => {
|
||||
await loadTasks(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopListPolling() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
isPolling.value = false
|
||||
}
|
||||
|
||||
function toggleListPolling() {
|
||||
if (isPolling.value) stopListPolling()
|
||||
else startListPolling()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const detailIsTerminal = 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))
|
||||
|
||||
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 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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTasks(true)
|
||||
startListPolling()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopListPolling()
|
||||
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>
|
||||
<div class="ingest page-section">
|
||||
<div class="ingest__header">
|
||||
<h2 class="ingest__title"><UploadOutlined /> 文档入库</h2>
|
||||
</div>
|
||||
|
||||
<div class="ingest__card">
|
||||
<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__batch">
|
||||
<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 === '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}`"
|
||||
/>
|
||||
<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="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">
|
||||
<h3 class="ingest__section-title">L1 总结</h3>
|
||||
<pre class="ingest__pre">{{ partialSummary.l1_summary }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ingest__card">
|
||||
<div class="ingest__tasks-header">
|
||||
<h3 class="ingest__tasks-title"><ClockCircleOutlined /> 入库任务(共 {{ total }} 条)</h3>
|
||||
<div class="ingest__tasks-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="toggleListPolling">
|
||||
{{ isPolling ? '停止轮询' : '开始轮询' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="isPolling"
|
||||
type="info"
|
||||
show-icon
|
||||
message="每 2 秒自动刷新近期入库任务。"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<template v-if="!selectedTask">
|
||||
<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="detailIsTerminal && selectedTask.status === 'done' && detailResult" class="ingest__block">
|
||||
<h4 class="ingest__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="ingest__block-title">L1 总结</h4>
|
||||
<pre class="ingest__pre">{{ detailSummary.l1_summary || '' }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="detailIsTerminal && selectedTask.status === 'failed' && detailError" class="ingest__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="ingest__block-title">L1 总结(部分)</h4>
|
||||
<pre class="ingest__pre">{{ detailError.partial_summary.l1_summary }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="ingest__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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ingest__header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ingest__title {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ingest__card {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ingest__batch {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ingest__alert {
|
||||
margin: 8px 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__section-title {
|
||||
font-size: 14px;
|
||||
margin: 16px 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;
|
||||
}
|
||||
|
||||
.ingest__tasks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ingest__tasks-title {
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ingest__tasks-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ingest__block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ingest__block-title {
|
||||
font-size: 13px;
|
||||
margin: 12px 0 6px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.ingest__actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,12 +4,11 @@ import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
FolderOutlined,
|
||||
FileTextOutlined,
|
||||
UploadOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
ClockCircleOutlined,
|
||||
TagsOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
categories as fetchCategories,
|
||||
stats as fetchStats
|
||||
@@ -18,16 +17,10 @@ import {
|
||||
list as fetchDocuments,
|
||||
detail as fetchDocumentDetail,
|
||||
remove as deleteDocument,
|
||||
reingest as reingestDocument,
|
||||
ingest as ingestDocument,
|
||||
upload as uploadDocument,
|
||||
uploadBatch
|
||||
reingest as reingestDocument
|
||||
} 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 router = useRouter()
|
||||
|
||||
/* ---------- 树形目录 ---------- */
|
||||
const isLoading = ref(false)
|
||||
@@ -231,147 +224,6 @@ function handleReingest(doc) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 入库抽屉 ---------- */
|
||||
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>
|
||||
@@ -391,11 +243,7 @@ const failedColumns = [
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button @click="tasksDrawer.open()">
|
||||
<template #icon><ClockCircleOutlined /></template>
|
||||
入库进度
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openIngest">
|
||||
<a-button type="primary" @click="router.push({ name: 'ingest' })">
|
||||
<template #icon><UploadOutlined /></template>
|
||||
入库
|
||||
</a-button>
|
||||
@@ -498,156 +346,6 @@ const failedColumns = [
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 入库抽屉 -->
|
||||
<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>
|
||||
|
||||
@@ -789,31 +487,6 @@ const failedColumns = [
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user