diff --git a/frontend/src/api/documents.js b/frontend/src/api/documents.js index 799b914..047fe92 100644 --- a/frontend/src/api/documents.js +++ b/frontend/src/api/documents.js @@ -52,6 +52,37 @@ export function upload(formData) { }) } +/** + * multipart 批量文件上传入库(异步,逐文件创建任务) + * @param {File[]} files + * @returns {Promise<{tasks: Array<{filename:string, task_id:string}>, failed: Array<{filename:string, error:string}>}>} + */ +export function uploadBatch(files) { + const formData = new FormData() + files.forEach((f) => formData.append('files', f)) + return http.post('/api/v1/documents/upload-batch', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + +/** + * 重新摘要入库(读原文件→删旧数据→新建任务) + * @param {string} docId + * @returns {Promise<{task_id:string, status:string}>} + */ +export function reingest(docId) { + return http.post(`/api/v1/documents/${encodeURIComponent(docId)}/reingest`) +} + +/** + * 列出近期入库任务(按 updated_at 降序) + * @param {number} [limit=20] + * @returns {Promise<{items: Array, total: number}>} + */ +export function taskList(limit = 20) { + return http.get('/api/v1/documents/tasks', { params: { limit } }) +} + /** * 查询入库任务状态 * @param {string} taskId diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index ba6a5a6..b538556 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -15,7 +15,8 @@ import { LogoutOutlined, DatabaseOutlined, ApiOutlined, - TeamOutlined + TeamOutlined, + ClockCircleOutlined } from '@ant-design/icons-vue' import { useAuthStore } from '@/stores/useAuthStore' import { changeMyPassword } from '@/api/auth' @@ -39,6 +40,7 @@ const menuItems = computed(() => { { key: 'overview', icon: () => h(DashboardOutlined), label: '概览' }, { key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' }, { key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' }, + { key: 'tasks', icon: () => h(ClockCircleOutlined), label: '入库进度' }, { key: 'search', icon: () => h(SearchOutlined), label: '检索测试台' }, { key: 'categories', icon: () => h(AppstoreOutlined), label: '类目列表' }, { key: 'settings', icon: () => h(SettingOutlined), label: '设置' }, diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 8931919..b593ecb 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -32,6 +32,12 @@ const routes = [ component: () => import('@/views/Ingest.vue'), meta: { title: '文档入库', requiresAuth: true } }, + { + path: 'tasks', + name: 'tasks', + component: () => import('@/views/Tasks.vue'), + meta: { title: '入库进度', requiresAuth: true } + }, { path: 'search', name: 'search', diff --git a/frontend/src/views/Documents.vue b/frontend/src/views/Documents.vue index 5c850c3..110c24c 100644 --- a/frontend/src/views/Documents.vue +++ b/frontend/src/views/Documents.vue @@ -5,7 +5,8 @@ import { FileOutlined } from '@ant-design/icons-vue' import { list as fetchDocuments, detail as fetchDocumentDetail, - remove as deleteDocument + remove as deleteDocument, + reingest as reingestDocument } from '@/api/documents' import { truncate } from '@/utils/format' @@ -19,6 +20,7 @@ function formatSize(bytes) { const isLoading = ref(false) const isLoadingDetail = ref(false) const isDeleting = ref(false) +const isReingesting = ref(false) const documents = ref([]) const nextOffset = ref(null) @@ -99,6 +101,26 @@ function handleDelete(doc) { }) } +// 重新摘要入库:读原文件→删旧数据→提交新任务 +function handleReingest(doc) { + const title = doc.title || doc.doc_id + Modal.confirm({ + title: '重新摘要入库', + content: `将重新读取文档「${title}」(${doc.doc_id}) 的原始文件,删除旧数据后重新生成三级总结并入库。生成新的 doc_id。是否继续?`, + okText: '重新入库', + cancelText: '取消', + okType: 'primary', + async onOk() { + try { + const data = await reingestDocument(doc.doc_id) + message.success(`已提交重新入库任务:${data.task_id}`) + } catch (err) { + message.error(err?.message || '重新入库失败') + } + } + }) +} + function handleCloseDetail() { detailVisible.value = false detailData.value = null @@ -109,7 +131,7 @@ const columns = [ { title: '类目', dataIndex: 'category', key: 'category', width: 140 }, { title: '标签', dataIndex: 'tags', key: 'tags', width: 220 }, { title: 'L1 摘要', dataIndex: 'summary', key: 'summary', ellipsis: true }, - { title: '操作', key: 'action', width: 160, fixed: 'right' } + { title: '操作', key: 'action', width: 200, fixed: 'right' } ] function getDocTitle(record) { @@ -158,6 +180,14 @@ onMounted(() => { 详情 + + 重新摘要 + import { computed, reactive, ref } from 'vue' import { message } from 'ant-design-vue' -import { ingest as ingestDocument, upload as uploadDocument } from '@/api/documents' +import { + ingest as ingestDocument, + upload as uploadDocument, + uploadBatch +} from '@/api/documents' import { useIngestPolling } from '@/composables/useIngestPolling' import { INGEST_STATUS_TEXT, @@ -34,12 +38,26 @@ const fileForm = reactive({ const fileList = ref([]) const rawFile = ref(null) +// 批量上传结果:{tasks: [{filename, task_id}], failed: [{filename, error}]} +const batchResult = ref(null) +const isSubmittingBatch = ref(false) + const ACCEPTED_EXTENSIONS = '.txt,.md,.html,.htm,.pdf,.docx' const statusBadgeColor = computed(() => { return INGEST_STATUS_COLOR[pollState.status] || 'default' }) +const batchColumns = [ + { title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true }, + { title: 'task_id', dataIndex: 'task_id', key: 'task_id', width: 260 } +] + +const failedColumns = [ + { title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true }, + { title: '错误', dataIndex: 'error', key: 'error' } +] + const statusBadgeText = computed(() => { return INGEST_STATUS_TEXT[pollState.status] || pollState.status || '-' }) @@ -60,9 +78,28 @@ function handleFileChange(file) { return false } -function handleFileRemove() { +function handleFileRemove(file) { rawFile.value = null fileList.value = [] + batchResult.value = null +} + +// 批量上传:before-upload 返回 false,收集多个 File 到 fileList +function handleBatchFileChange(file, fileListArg) { + const files = fileListArg.map((f) => f.originFileObj || f) + fileList.value = files + batchResult.value = null + return false +} + +function handleBatchRemove(file) { + fileList.value = fileList.value.filter((f) => f !== file && f.name !== file.name) + batchResult.value = null +} + +function handleBatchClear() { + fileList.value = [] + batchResult.value = null } async function handleSubmitText() { @@ -115,6 +152,33 @@ async function handleSubmitFile() { } } +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('已停止轮询') @@ -208,6 +272,85 @@ function handleCancelPolling() { + + + + + + 选择文件 + +
+ 支持 .txt/.md/.html/.htm/.pdf/.docx,可多选,逐文件异步入库 +
+
+ + + 批量上传入库({{ fileList.length }} 个文件) + + + 清空 + + +
+ +
+
+ 批量上传结果 +
+ + + + + + + +
+
diff --git a/frontend/src/views/Tasks.vue b/frontend/src/views/Tasks.vue new file mode 100644 index 0000000..72584a4 --- /dev/null +++ b/frontend/src/views/Tasks.vue @@ -0,0 +1,179 @@ + + + + + \ No newline at end of file