feat: 前端补齐批量上传、重新摘要入库与入库进度页
- api/documents.js: 新增 uploadBatch / reingest / taskList 封装 - Ingest.vue: 新增「批量上传」标签页,多文件逐项异步入库并展示结果 - Documents.vue: 文档操作列新增「重新摘要」按钮(读原文件→删旧→新任务) - 新增 Tasks.vue 入库进度页:近期任务列表 + 2s 自动轮询 + 状态徽标 - 路由与菜单新增「入库进度」入口
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: '设置' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user