feat: 前端补齐批量上传、重新摘要入库与入库进度页

- api/documents.js: 新增 uploadBatch / reingest / taskList 封装
- Ingest.vue: 新增「批量上传」标签页,多文件逐项异步入库并展示结果
- Documents.vue: 文档操作列新增「重新摘要」按钮(读原文件→删旧→新任务)
- 新增 Tasks.vue 入库进度页:近期任务列表 + 2s 自动轮询 + 状态徽标
- 路由与菜单新增「入库进度」入口
This commit is contained in:
2026-08-04 11:52:14 +08:00
parent bdd30f0a88
commit f0fc20a9b6
6 changed files with 396 additions and 5 deletions
+31
View File
@@ -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
+3 -1
View File
@@ -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: '设置' },
+6
View File
@@ -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',
+32 -2
View File
@@ -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(() => {
<a-button type="link" size="small" @click="handleViewDetail(record.doc_id)">
详情
</a-button>
<a-button
type="link"
size="small"
:loading="isReingesting"
@click="handleReingest(record)"
>
重新摘要
</a-button>
<a-button
type="link"
size="small"
+145 -2
View File
@@ -1,7 +1,11 @@
<script setup>
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() {
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="batch" tab="批量上传">
<a-form :model="fileForm" layout="vertical">
<a-form-item label="选择文件(可多选)">
<a-upload
multiple
:file-list="fileList"
:accept="ACCEPTED_EXTENSIONS"
:before-upload="handleBatchFileChange"
@remove="handleBatchRemove"
>
<a-button>选择文件</a-button>
</a-upload>
<div class="text-muted" style="margin-top: 4px">
支持 .txt/.md/.html/.htm/.pdf/.docx可多选逐文件异步入库
</div>
</a-form-item>
<a-form-item v-if="fileList.length">
<a-button type="primary" :loading="isSubmittingBatch" @click="handleSubmitBatch">
批量上传入库{{ fileList.length }} 个文件
</a-button>
<a-button
style="margin-left: 8px"
:disabled="isSubmittingBatch"
@click="handleBatchClear"
>
清空
</a-button>
</a-form-item>
</a-form>
<div v-if="batchResult" class="ingest__result" style="margin-top: 16px">
<div class="ingest__result-header">
<span>批量上传结果</span>
</div>
<a-alert
v-if="batchResult.failed && batchResult.failed.length"
class="ingest__alert"
type="error"
show-icon
:message="`${batchResult.failed.length} 个文件上传失败`"
/>
<a-table
v-if="batchResult.tasks && batchResult.tasks.length"
:data-source="batchResult.tasks"
:columns="batchColumns"
:pagination="false"
size="small"
row-key="task_id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'filename'">
<span :title="record.filename">{{ record.filename }}</span>
</template>
<template v-else-if="column.key === 'task_id'">
<a-typography-text
copyable
:style="{ fontSize: '12px' }"
>{{ record.task_id }}</a-typography-text>
</template>
</template>
</a-table>
<a-table
v-if="batchResult.failed && batchResult.failed.length"
:data-source="batchResult.failed"
:columns="failedColumns"
:pagination="false"
size="small"
row-key="filename"
style="margin-top: 12px"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'error'">
<span class="text-break">{{ record.error }}</span>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
<div v-if="pollState.taskId" class="ingest__result">
+179
View File
@@ -0,0 +1,179 @@
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { taskList } from '@/api/documents'
import {
INGEST_STATUS_TEXT,
INGEST_STATUS_COLOR
} from '@/constants/ingest'
const tasks = ref([])
const total = ref(0)
const isLoading = ref(false)
const isPolling = ref(false)
const limit = ref(50)
let timer = null
function formatTime(iso) {
if (!iso) return '-'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function statusText(s) {
return INGEST_STATUS_TEXT[s] || s || '-'
}
function statusColor(s) {
return INGEST_STATUS_COLOR[s] || 'default'
}
async function loadTasks(showSpinner = false) {
if (showSpinner) isLoading.value = true
try {
const data = await taskList(limit.value)
tasks.value = data.items || []
total.value = data.total ?? tasks.value.length
} catch (err) {
message.error(err?.message || '加载入库任务失败')
} finally {
if (showSpinner) isLoading.value = false
}
}
function startPolling() {
if (timer) return
isPolling.value = true
timer = setInterval(async () => {
await loadTasks(false)
}, 2000)
}
function stopPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
isPolling.value = false
}
function togglePolling() {
if (isPolling.value) {
stopPolling()
} else {
startPolling()
}
}
function handleRefresh() {
loadTasks(true)
}
onMounted(() => {
loadTasks(true)
startPolling()
})
onBeforeUnmount(() => {
stopPolling()
})
const columns = [
{ title: 'task_id', dataIndex: 'task_id', key: 'task_id', width: 260 },
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
{ title: '状态', dataIndex: 'status', key: 'status', width: 110 },
{ title: 'doc_id', dataIndex: 'doc_id', key: 'doc_id', width: 260 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 170 }
]
</script>
<template>
<div class="tasks page-section">
<div class="tasks__header">
<h2 class="tasks__title">入库进度</h2>
<div class="tasks__actions">
<span class="text-muted" style="margin-right: 8px"> {{ total }} </span>
<a-button size="small" :loading="isLoading" @click="handleRefresh">刷新</a-button>
<a-button size="small" :type="isPolling ? 'default' : 'primary'" @click="togglePolling">
{{ isPolling ? '停止轮询' : '开始轮询' }}
</a-button>
</div>
</div>
<a-alert
v-if="isPolling"
class="tasks__alert"
type="info"
show-icon
message="每 2 秒自动刷新,展示近期上传文件与摘要入库进度。"
/>
<a-table
:columns="columns"
:data-source="tasks"
:pagination="false"
:loading="isLoading"
row-key="task_id"
size="small"
:scroll="{ x: 1100 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'task_id'">
<a-typography-text copyable :style="{ fontSize: '12px' }">
{{ record.task_id }}
</a-typography-text>
</template>
<template v-else-if="column.key === 'filename'">
<span :title="record.filename">{{ record.filename || '-' }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'doc_id'">
<a-typography-text
v-if="record.doc_id"
copyable
:style="{ fontSize: '12px' }"
>
{{ record.doc_id }}
</a-typography-text>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'updated_at'">
{{ formatTime(record.updated_at) }}
</template>
</template>
</a-table>
</div>
</template>
<style scoped>
.tasks__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.tasks__title {
font-size: 16px;
margin: 0;
}
.tasks__actions {
display: flex;
align-items: center;
gap: 8px;
}
.tasks__alert {
margin-bottom: 12px;
}
</style>