feat: 入库改为独立页面,更新 API 说明文档与 Agent skill

- 新增 Ingest.vue 独立入库页(文本/文件/批量 + 任务进度列表),移除入库 Drawer
- 导航栏去掉「入库进度」,新增「文档入库」入口;知识库页「入库」跳转独立页
- API 说明页接口清单与后端对齐(补齐用户管理/上传批量/任务重试删除/reingest 等,修正鉴权标注)
- 更新 qmdsearch-agent skill 与 api-examples(鉴权说明、会话 token、新端点示例)
This commit is contained in:
2026-08-04 20:50:53 +08:00
parent 224eac0048
commit db568740c5
7 changed files with 843 additions and 381 deletions
+2 -12
View File
@@ -13,19 +13,16 @@ import {
DatabaseOutlined, DatabaseOutlined,
ApiOutlined, ApiOutlined,
TeamOutlined, TeamOutlined,
ClockCircleOutlined UploadOutlined
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
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)
@@ -39,7 +36,7 @@ const menuItems = computed(() => {
const items = [ const items = [
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' }, { key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' },
{ key: 'library', icon: () => h(AppstoreOutlined), 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: 'settings', icon: () => h(SettingOutlined), label: '设置' },
{ key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' } { key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' }
] ]
@@ -50,10 +47,6 @@ 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 })
} }
@@ -195,9 +188,6 @@ 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"
+6
View File
@@ -26,6 +26,12 @@ const routes = [
component: () => import('@/views/Library.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: 'settings', path: 'settings',
name: 'settings', name: 'settings',
+43 -19
View File
@@ -2,21 +2,33 @@
import { computed } from 'vue' import { computed } from 'vue'
import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined, DownloadOutlined } from '@ant-design/icons-vue' import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined, DownloadOutlined } from '@ant-design/icons-vue'
// 以下内容整理自项目 README.md 的「API 文档」章节 // 以下内容与后端 app/api/v1 的路由实现保持一致
const apiList = [ const apiList = [
{ method: 'GET', path: '/api/v1/health', desc: '健康检查', auth: false }, { 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: '用户登录,签发 session tokenTTL 12h', auth: false },
{ method: 'POST', path: '/api/v1/auth/login', desc: '用户登录,返回 JWT token', auth: false }, { method: 'POST', path: '/api/v1/auth/logout', desc: '退出登录(删除当前会话)', auth: true },
{ method: 'GET', path: '/api/v1/auth/me', desc: '获取当前用户信息', auth: true }, { method: 'POST', path: '/api/v1/auth/password', desc: '修改自己的密码', auth: true },
{ method: 'POST', path: '/api/v1/search', 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', desc: '文档入库(JSON 文本,202 异步入库)', auth: true },
{ method: 'POST', path: '/api/v1/documents/upload', desc: '文件上传入库(multipart202 异步)', auth: true }, { method: 'POST', path: '/api/v1/documents/upload', desc: '文件上传入库(multipart202 异步)', auth: true },
{ method: 'GET', path: '/api/v1/documents/tasks/{task_id}', desc: '入库任务状态查询', auth: true }, { method: 'POST', path: '/api/v1/documents/upload-batch', desc: '批量文件上传入库(多文件,202 异步)', auth: true },
{ method: 'GET', path: '/api/v1/documents', desc: '文档列表(分页', auth: true }, { method: 'GET', path: '/api/v1/documents/tasks', desc: '入库任务列表(按时间降序', auth: true },
{ method: 'GET', path: '/api/v1/documents/{doc_id}', 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: '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/categories', desc: '知识分类类目集', auth: false },
{ method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: true }, { method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: false },
{ method: 'GET', path: '/admin', desc: '管理后台(本页)', auth: false } { method: 'GET', path: '/admin', desc: '管理后台(本页)', auth: false }
] ]
@@ -24,7 +36,18 @@ const methodColor = {
GET: 'green', GET: 'green',
POST: 'blue', POST: 'blue',
DELETE: 'red', 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\/$/, '')) 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 \\ const loginExample = `curl -X POST ${baseUrl.value}/api/v1/auth/login \\
-H "Content-Type: application/json" \\ -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 \\ const searchExample = `curl -X POST ${baseUrl.value}/api/v1/search \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-H "Authorization: Bearer <token>" \\ -d '{"query": "如何配置 Redis 缓存", "top_k": 5, "summarize": false}'`
-d '{"query": "如何配置 Redis 缓存", "top_k": 5}'`
const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
-H "Authorization: Bearer <token>" \\ -H "Authorization: Bearer <token>" \\
-F "file=@document.pdf"` -F "file=@document.pdf" \\
-F "source=manual"`
</script> </script>
<template> <template>
@@ -58,7 +82,7 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
class="apidocs__tip" class="apidocs__tip"
type="info" type="info"
show-icon show-icon
message="需要鉴权的接口请在请求头携带 Authorization: Bearer &lt;token&gt;token 通过 /auth/login 获取。" message="需要鉴权的接口请在请求头携带 Authorization: Bearer &lt;token&gt;token 通过 /auth/login 获取;用户管理类接口(/auth/users*)仅 admin 角色可用。查询类接口(检索 / 文档列表 / 详情 / 下载 / 类目 / 统计 / 任务状态)免登录。"
/> />
<!-- AI Agent Skill 下载 --> <!-- AI Agent Skill 下载 -->
@@ -117,8 +141,8 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
<code class="api-path">{{ text }}</code> <code class="api-path">{{ text }}</code>
</template> </template>
<template v-else-if="column.key === 'auth'"> <template v-else-if="column.key === 'auth'">
<a-tag :color="record.auth ? 'volcano' : 'default'"> <a-tag :color="authColor(record.auth)">
{{ record.auth ? '需鉴权' : '否' }} {{ authLabel(record.auth) }}
</a-tag> </a-tag>
</template> </template>
</template> </template>
@@ -130,7 +154,7 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
<h3 class="section-subtitle"> <h3 class="section-subtitle">
<SafetyCertificateOutlined /> 获取 Token <SafetyCertificateOutlined /> 获取 Token
</h3> </h3>
<p class="apidocs__p">先调用登录接口获取 JWT再在后续请求 Header 中携带</p> <p class="apidocs__p">先调用登录接口获取 session token再在后续需要鉴权的请求 Header 中携带</p>
<pre class="code-block">{{ loginExample }}</pre> <pre class="code-block">{{ loginExample }}</pre>
</section> </section>
+729
View File
@@ -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 -331
View File
@@ -4,12 +4,11 @@ import { message, Modal } from 'ant-design-vue'
import { import {
FolderOutlined, FolderOutlined,
FileTextOutlined, FileTextOutlined,
UploadOutlined,
ReloadOutlined, ReloadOutlined,
SearchOutlined, SearchOutlined,
ClockCircleOutlined,
TagsOutlined TagsOutlined
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { useRouter } from 'vue-router'
import { import {
categories as fetchCategories, categories as fetchCategories,
stats as fetchStats stats as fetchStats
@@ -18,16 +17,10 @@ import {
list as fetchDocuments, list as fetchDocuments,
detail as fetchDocumentDetail, detail as fetchDocumentDetail,
remove as deleteDocument, remove as deleteDocument,
reingest as reingestDocument, reingest as reingestDocument
ingest as ingestDocument,
upload as uploadDocument,
uploadBatch
} from '@/api/documents' } 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) 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> </script>
<template> <template>
@@ -391,11 +243,7 @@ const failedColumns = [
<template #icon><ReloadOutlined /></template> <template #icon><ReloadOutlined /></template>
刷新 刷新
</a-button> </a-button>
<a-button @click="tasksDrawer.open()"> <a-button type="primary" @click="router.push({ name: 'ingest' })">
<template #icon><ClockCircleOutlined /></template>
入库进度
</a-button>
<a-button type="primary" @click="openIngest">
<template #icon><UploadOutlined /></template> <template #icon><UploadOutlined /></template>
入库 入库
</a-button> </a-button>
@@ -498,156 +346,6 @@ const failedColumns = [
</div> </div>
</div> </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> </div>
</template> </template>
@@ -789,31 +487,6 @@ const failedColumns = [
margin-bottom: 4px; 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) { @media (max-width: 768px) {
.library__body { .library__body {
flex-direction: column; flex-direction: column;
+28 -16
View File
@@ -25,8 +25,8 @@ metadata: {"clawdbot":{"emoji":"📚"}}
- `QMDSEARCH_BASE_URL`:服务基地址,例如 `http://localhost:8000` 或 NAS 地址 `http://<nas-ip>:8000` - `QMDSEARCH_BASE_URL`:服务基地址,例如 `http://localhost:8000` 或 NAS 地址 `http://<nas-ip>:8000`
- `QMDSEARCH_TOKEN`Bearer Token。通过 `POST {BASE}/api/v1/auth/login`(用户名 + 密码)获取;session TTL 12h,失效后重新登录。 - `QMDSEARCH_TOKEN`Bearer Token。通过 `POST {BASE}/api/v1/auth/login`(用户名 + 密码)获取;session TTL 12h,失效后重新登录。
> 所有变更类请求(上传 / 删除)与检索请求都必须在 Header 携带 `Authorization: Bearer <token>`。 > 变更类请求(上传 / 删除 / 重试 / 用户管理)必须携带 `Authorization: Bearer <token>`。
> 当前代码实现中,查询类只读接口(文档列表 / 详情 / 类目 / 统计 / 健康)同样要求 Bearer(与登录态绑定),请始终携带 token 以避免 `1005` 未认证 > 查询类只读接口(检索 / 文档列表 / 详情 / 下载 / 类目 / 统计 / 健康 / 任务状态)免登录,无需携带 token
## 统一约定 ## 统一约定
@@ -40,7 +40,7 @@ metadata: {"clawdbot":{"emoji":"📚"}}
curl -X POST {BASE}/api/v1/auth/login \ curl -X POST {BASE}/api/v1/auth/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"username":"admin","password":"<password>"}' -d '{"username":"admin","password":"<password>"}'
# → {"code":0,"data":{"token":"<JWT>","username":"admin","role":"admin","must_change_password":false},"message":"ok"} # → {"code":0,"data":{"token":"<session-token>","username":"admin","role":"admin","must_change_password":false},"message":"ok"}
``` ```
`must_change_password=true` 时,登录成功但调用其他接口会返回 `1006`,需先 `POST /api/v1/auth/password` 改密。 `must_change_password=true` 时,登录成功但调用其他接口会返回 `1006`,需先 `POST /api/v1/auth/password` 改密。
@@ -96,7 +96,6 @@ curl {BASE}/api/v1/documents/tasks/<task_id>
```bash ```bash
curl -X POST {BASE}/api/v1/search \ curl -X POST {BASE}/api/v1/search \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}' -d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}'
``` ```
@@ -111,18 +110,31 @@ curl -X POST {BASE}/api/v1/search \
## 6. 其他管理接口(按需) ## 6. 其他管理接口(按需)
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 | 鉴权 |
|------|------|------| |------|------|------|------|
| POST | `/documents` | 文本入库(202 + task_id | | POST | `/documents` | 文本入库(202 + task_id | Bearer |
| POST | `/documents/upload` | 文件入库(202 + task_id | | POST | `/documents/upload` | 文件入库(202 + task_id | Bearer |
| GET | `/documents/tasks/{task_id}` | 任务状态(done / failed | | POST | `/documents/upload-batch` | 批量文件入库(202,逐文件建任务) | Bearer |
| GET | `/documents?limit=&offset=` | 文档列表(分页游标) | | GET | `/documents/tasks` | 入库任务列表(按时间降序) | Bearer |
| GET | `/documents/{doc_id}` | 文档详情(L1 + L2/L3 + chunks 数) | | GET | `/documents/tasks/{task_id}` | 任务状态(done / failed | 免登录 |
| GET | `/documents/{doc_id}/file` | 下载原始文件 | | POST | `/documents/tasks/{task_id}/retry` | 重试入库任务(返回新 task_id | Bearer |
| DELETE | `/documents/{doc_id}` | 删除文档(幂等) | | DELETE | `/documents/tasks/{task_id}` | 删除入库任务(幂等) | Bearer |
| GET | `/knowledge/categories` | 知识分类类目集 | | GET | `/documents?limit=&offset=` | 文档列表(分页游标) | 免登录 |
| GET | `/knowledge/stats` | 统计(四层点数 + 类目分布) | | GET | `/documents/{doc_id}` | 文档详情(L1 + L2/L3 + chunks 数) | 免登录 |
| GET | `/health` | 健康检查 | | GET | `/documents/{doc_id}/file` | 下载原始文件 | 免登录 |
| POST | `/documents/{doc_id}/reingest` | 重新摘要入库(读原文件→删旧→重跑) | Bearer |
| DELETE | `/documents/{doc_id}` | 删除文档(幂等) | Bearer |
| GET | `/knowledge/categories` | 知识分类类目集 | 免登录 |
| GET | `/knowledge/stats` | 统计(四层点数 + 类目分布) | 免登录 |
| GET | `/health` | 健康检查 | 免登录 |
| POST | `/auth/logout` | 退出登录 | Bearer |
| POST | `/auth/password` | 修改自己的密码 | Bearer |
| GET | `/auth/me` | 当前用户信息(脱敏) | Bearer |
| GET | `/auth/users` | 用户列表(脱敏) | Bearer + admin |
| POST | `/auth/users` | 创建用户 | Bearer + admin |
| PATCH | `/auth/users/{username}` | 更新用户角色 / 启用状态 | Bearer + admin |
| POST | `/auth/users/{username}/password` | 重置指定用户密码 | Bearer + admin |
| DELETE | `/auth/users/{username}` | 删除用户 | Bearer + admin |
## 错误码速查 ## 错误码速查
@@ -36,11 +36,32 @@ curl -s -X POST {BASE}/api/v1/documents/upload \
curl -s {BASE}/api/v1/documents/tasks/<task_id> curl -s {BASE}/api/v1/documents/tasks/<task_id>
``` ```
### 检索 ### 批量文件上传(202,逐文件建任务)
```bash
curl -s -X POST {BASE}/api/v1/documents/upload-batch \
-H "Authorization: Bearer <token>" \
-F "files=@a.pdf" -F "files=@b.md"
```
### 列出入库任务 / 重试 / 删除任务
```bash
curl -s -H "Authorization: Bearer <token>" "{BASE}/api/v1/documents/tasks?limit=20"
curl -s -X POST -H "Authorization: Bearer <token>" {BASE}/api/v1/documents/tasks/<task_id>/retry
curl -s -X DELETE -H "Authorization: Bearer <token>" {BASE}/api/v1/documents/tasks/<task_id>
```
### 重新摘要入库(读原文件 → 删旧 → 重跑)
```bash
curl -s -X POST -H "Authorization: Bearer <token>" {BASE}/api/v1/documents/<doc_id>/reingest
```
### 检索(免登录)
```bash ```bash
curl -s -X POST {BASE}/api/v1/search \ curl -s -X POST {BASE}/api/v1/search \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}' -d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}'
``` ```
@@ -120,6 +141,13 @@ class QMDSearchClient:
headers=self._headers(), files=files, data=data, timeout=60) headers=self._headers(), files=files, data=data, timeout=60)
return self._ok(resp)["task_id"] return self._ok(resp)["task_id"]
def upload_batch(self, paths: list[str]) -> dict:
"""批量上传多个文件:返回 {tasks:[{filename,task_id}], failed:[{filename,error}]}"""
files = [("files", open(p, "rb")) for p in paths]
resp = requests.post(f"{self.base}/api/v1/documents/upload-batch",
headers=self._headers(), files=files, timeout=60)
return self._ok(resp)
# ---- 轮询 ---- # ---- 轮询 ----
def wait_task(self, task_id: str) -> dict: def wait_task(self, task_id: str) -> dict:
deadline = time.time() + self.poll_timeout deadline = time.time() + self.poll_timeout
@@ -165,6 +193,6 @@ if __name__ == "__main__":
## 三、响应结构速记 ## 三、响应结构速记
- 文本 / 文件入库 `202``data = {task_id, status:"pending"[, saved_path]}` - 文本 / 文件 / 批量入库 `202``data = {task_id, status:"pending"[, saved_path]}`;批量返回 `{tasks, failed}`
- 任务 `done``data.result = {document_id, summary:{l1_summary,l2_outline,l3_content_outline,level}, category, chunks_count, tags, category_confidence, deduplicated}` - 任务 `done``data.result = {document_id, summary:{l1_summary,l2_outline,l3_content_outline,level}, category, chunks_count, tags, category_confidence, deduplicated}`
- 检索 → `data = {query, hits:[{text,doc_id,title,section_path,score,doc_summary}], routed_categories, fallback, extracted_info, summary?}` - 检索 → `data = {query, hits:[{text,doc_id,title,section_path,score,doc_summary}], routed_categories, fallback, extracted_info, summary?}`