Stabilize knowledge uploads in the UI

Keep folder selection stable across refreshes, surface upload failures
more clearly, and add focused composable tests for the knowledge page.
This keeps newly uploaded files visible and makes MinerU dependency
errors easier to understand from the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 13:43:00 +08:00
parent 3ee825aa90
commit e3691b01bb
5 changed files with 2106 additions and 22 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@vueuse/core": "^14.2.1",
@@ -22,9 +23,12 @@
"devDependencies": {
"@types/node": "^25.5.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.0",
"jsdom": "^29.0.1",
"typescript": "^5.9.3",
"vite": "^8.0.1",
"vitest": "^4.1.0",
"vue-tsc": "^2.2.12"
}
}

View File

@@ -0,0 +1,332 @@
import { AxiosError } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const storage = new Map<string, string>()
const routeQuery = { folder_id: undefined as string | undefined }
const routerReplace = vi.fn(async ({ query }: { query: Record<string, unknown> }) => {
routeQuery.folder_id = typeof query.folder_id === 'string' ? query.folder_id : undefined
})
const mocks = vi.hoisted(() => ({
folderGetTree: vi.fn(),
folderCreate: vi.fn(),
documentList: vi.fn(),
documentUpload: vi.fn(),
documentGetContent: vi.fn(),
documentGetChunks: vi.fn(),
documentUpdateChunk: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: routeQuery }),
useRouter: () => ({ replace: routerReplace }),
}))
vi.mock('@/api/folder', () => ({
folderApi: {
getTree: mocks.folderGetTree,
create: mocks.folderCreate,
},
}))
vi.mock('@/api/document', () => ({
documentApi: {
list: mocks.documentList,
upload: mocks.documentUpload,
getContent: mocks.documentGetContent,
getChunks: mocks.documentGetChunks,
updateChunk: mocks.documentUpdateChunk,
},
}))
import { useKnowledgeView } from './useKnowledgeView'
describe('useKnowledgeView chunk editing', () => {
beforeEach(() => {
vi.clearAllMocks()
routeQuery.folder_id = undefined
routerReplace.mockClear()
storage.clear()
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => storage.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
storage.set(key, value)
}),
removeItem: vi.fn((key: string) => {
storage.delete(key)
}),
})
mocks.folderGetTree.mockResolvedValue({ data: [] })
mocks.documentList.mockResolvedValue({ data: [] })
mocks.documentUpload.mockResolvedValue({
data: {
id: 'doc-2',
title: 'New doc',
chunk_count: 1,
status: '上传成功,正在索引...',
},
})
mocks.documentGetContent.mockResolvedValue({ data: { content: 'document preview' } })
mocks.documentGetChunks.mockResolvedValue({
data: [
{
id: 'chunk-1',
chunk_index: 0,
content: 'original chunk content',
metadata_: '{"content_type":"paragraph","section_title":"Intro"}',
},
],
})
mocks.documentUpdateChunk.mockResolvedValue({
data: {
id: 'chunk-1',
chunk_index: 0,
content: 'edited chunk content',
metadata_: '{"content_type":"paragraph","section_title":"Intro"}',
},
})
})
it('loads chunks when opening a document and updates local chunk state after save', async () => {
const view = useKnowledgeView()
const document = {
id: 'doc-1',
title: 'Test doc',
filename: 'test.md',
file_type: 'md',
file_size: 128,
chunk_count: 1,
is_indexed: true,
created_at: '2026-03-22T00:00:00Z',
}
await view.openDocument(document)
expect(mocks.documentGetChunks).toHaveBeenCalledWith('doc-1')
expect(view.activeDocumentChunks.value).toHaveLength(1)
expect(view.activeDocumentChunks.value[0].content).toBe('original chunk content')
view.startChunkEdit(view.activeDocumentChunks.value[0])
view.chunkDrafts.value['chunk-1'] = 'edited chunk content'
await view.saveChunkEdit('chunk-1')
expect(mocks.documentUpdateChunk).toHaveBeenCalledWith('doc-1', 'chunk-1', { content: 'edited chunk content' })
expect(view.activeDocumentChunks.value[0].content).toBe('edited chunk content')
expect(view.chunkEditError.value['chunk-1']).toBe('')
})
it('shows a newly uploaded document immediately in the current folder list', async () => {
const view = useKnowledgeView()
const folder = {
id: 'folder-1',
name: '测试',
parent_id: null,
children: [],
created_at: '2026-03-22T00:00:00Z',
}
mocks.folderGetTree.mockResolvedValue({ data: [folder] })
mocks.documentList
.mockResolvedValueOnce({ data: [] })
.mockResolvedValueOnce({ data: [] })
const file = new File(['hello'], 'new-doc.md', { type: 'text/markdown' })
const event = { target: { files: [file], value: 'new-doc.md' } } as unknown as Event
await view.enterFolder(folder)
await view.handleUpload(event)
expect(view.documents.value).toHaveLength(1)
expect(view.documents.value[0].id).toBe('doc-2')
expect(view.documents.value[0].title).toBe('New doc')
expect(view.documents.value[0].filename).toBe('new-doc.md')
expect(view.documents.value[0].folder_id).toBe('folder-1')
expect(routeQuery.folder_id).toBe('folder-1')
expect(view.highlightedDocumentId.value).toBe('doc-2')
expect(view.uploadSuccess.value).toBe('已上传到 测试')
})
it('restores the selected folder and reloads its documents after refresh', async () => {
routeQuery.folder_id = 'folder-1'
storage.set('knowledge.currentFolderId', 'folder-1')
const folder = {
id: 'folder-1',
name: '测试',
parent_id: null,
children: [],
created_at: '2026-03-22T00:00:00Z',
}
const view = useKnowledgeView()
mocks.folderGetTree.mockResolvedValue({ data: [folder] })
mocks.documentList.mockResolvedValue({
data: [
{
id: 'doc-3',
title: 'Persisted doc',
filename: 'persisted.md',
file_type: 'md',
file_size: 512,
chunk_count: 2,
is_indexed: true,
folder_id: 'folder-1',
created_at: '2026-03-22T00:00:00Z',
},
],
})
await view.goToFolder('folder-1')
expect(view.currentFolderId.value).toBe('folder-1')
expect(mocks.documentList).toHaveBeenCalledWith('folder-1')
expect(view.documents.value).toHaveLength(1)
expect(view.documents.value[0].id).toBe('doc-3')
})
it('enters a newly created folder so refresh keeps uploaded documents visible', async () => {
const createdFolder = {
id: 'folder-new',
name: '新文件夹',
parent_id: null,
children: [],
created_at: '2026-03-22T00:00:00Z',
updated_at: '2026-03-22T00:00:00Z',
}
const uploadedDocument = {
id: 'doc-new',
title: 'Uploaded after create',
filename: 'uploaded.md',
file_type: 'md',
file_size: 256,
chunk_count: 1,
is_indexed: true,
folder_id: 'folder-new',
created_at: '2026-03-22T00:00:00Z',
}
mocks.folderCreate.mockResolvedValue({ data: createdFolder })
mocks.folderGetTree.mockResolvedValue({
data: [
{
id: 'folder-new',
name: '新文件夹',
parent_id: null,
children: [],
},
],
})
mocks.documentList
.mockResolvedValueOnce({ data: [] })
.mockResolvedValueOnce({ data: [uploadedDocument] })
.mockResolvedValue({ data: [uploadedDocument] })
mocks.documentUpload.mockResolvedValue({
data: {
id: 'doc-new',
title: 'Uploaded after create',
chunk_count: 1,
status: '上传成功,正在索引...',
ingestion_status: 'ready',
},
})
const view = useKnowledgeView()
view.newFolderName.value = '新文件夹'
await view.createFolder()
expect(view.currentFolderId.value).toBe('folder-new')
expect(routeQuery.folder_id).toBe('folder-new')
expect(storage.get('knowledge.currentFolderId')).toBe('folder-new')
const file = new File(['hello'], 'uploaded.md', { type: 'text/markdown' })
const event = { target: { files: [file], value: 'uploaded.md' } } as unknown as Event
await view.handleUpload(event)
expect(mocks.documentUpload).toHaveBeenCalledWith(file, 'folder-new')
expect(view.documents.value).toHaveLength(1)
expect(view.documents.value[0].folder_id).toBe('folder-new')
})
it('loads documents at the root view instead of clearing the list', async () => {
const view = useKnowledgeView()
mocks.documentList.mockResolvedValue({
data: [
{
id: 'doc-root',
title: 'Root doc',
filename: 'root.md',
file_type: 'md',
file_size: 128,
chunk_count: 1,
is_indexed: true,
folder_id: null,
created_at: '2026-03-22T00:00:00Z',
},
],
})
await view.goToFolder(null)
expect(mocks.documentList).toHaveBeenCalledWith(null)
expect(view.documents.value).toHaveLength(1)
expect(view.documents.value[0].id).toBe('doc-root')
})
it('resets a stale persisted folder before creating a new folder', async () => {
routeQuery.folder_id = 'stale-folder'
storage.set('knowledge.currentFolderId', 'stale-folder')
mocks.folderGetTree.mockResolvedValue({ data: [] })
mocks.folderCreate.mockResolvedValue({
data: {
id: 'folder-fresh',
name: '全新文件夹',
parent_id: null,
created_at: '2026-03-22T00:00:00Z',
updated_at: '2026-03-22T00:00:00Z',
},
})
const view = useKnowledgeView()
await view.goToFolder(null)
view.newFolderName.value = '全新文件夹'
await view.createFolder()
expect(mocks.folderCreate).toHaveBeenCalledWith({
name: '全新文件夹',
parent_id: null,
})
})
it('shows a clear upload message when mineru dependency is missing', async () => {
const folder = {
id: 'folder-1',
name: '测试',
parent_id: null,
children: [],
created_at: '2026-03-22T00:00:00Z',
}
mocks.folderGetTree.mockResolvedValue({ data: [folder] })
mocks.documentList.mockResolvedValue({ data: [] })
mocks.documentUpload.mockRejectedValue(new AxiosError(
'Request failed with status code 400',
'400',
undefined,
undefined,
{
data: { detail: 'PDF 解析依赖缺失: mineru' },
status: 400,
statusText: 'Bad Request',
headers: {},
config: {} as never,
},
))
const view = useKnowledgeView()
await view.enterFolder(folder)
const file = new File(['%PDF-1.4'], 'spec.pdf', { type: 'application/pdf' })
const event = { target: { files: [file], value: 'spec.pdf' } } as unknown as Event
await view.handleUpload(event)
expect(view.uploadError.value).toBe('PDF 解析依赖缺失: mineru')
})
})

View File

@@ -1,15 +1,34 @@
import { computed, onMounted, ref } from 'vue'
import { documentApi, type Document } from '@/api/document'
import axios, { type AxiosError } from 'axios'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { documentApi, type Document, type DocumentChunk } from '@/api/document'
import { folderApi, type FolderTree } from '@/api/folder'
const KNOWLEDGE_FOLDER_STORAGE_KEY = 'knowledge.currentFolderId'
function getPersistedKnowledgeFolderId() {
return typeof window !== 'undefined' ? localStorage.getItem(KNOWLEDGE_FOLDER_STORAGE_KEY) : null
}
function normalizeFolderId(value: unknown) {
return typeof value === 'string' && value ? value : null
}
export function useKnowledgeView() {
const route = useRoute()
const router = useRouter()
const initialFolderId = normalizeFolderId(route.query.folder_id) ?? getPersistedKnowledgeFolderId()
const folders = ref<FolderTree[]>([])
const documents = ref<Document[]>([])
const currentFolderId = ref<string | null>(null)
const currentFolderId = ref<string | null>(initialFolderId)
const isUploading = ref(false)
const isLoadingDocuments = ref(false)
const currentFolderName = ref('')
const uploadError = ref('')
const uploadSuccess = ref('')
const highlightedDocumentId = ref<string | null>(null)
const uploadInput = ref<HTMLInputElement | null>(null)
const statusPoller = ref<number | null>(null)
const showNewFolderDialog = ref(false)
const newFolderName = ref('')
@@ -26,6 +45,12 @@ export function useKnowledgeView() {
const activeDocument = ref<Document | null>(null)
const activeDocumentContent = ref('')
const isLoadingDocumentContent = ref(false)
const activeDocumentChunks = ref<DocumentChunk[]>([])
const isLoadingDocumentChunks = ref(false)
const chunkDrafts = ref<Record<string, string>>({})
const chunkEditing = ref<Record<string, boolean>>({})
const chunkSaving = ref<Record<string, boolean>>({})
const chunkEditError = ref<Record<string, string>>({})
const folderMap = computed(() => {
const map = new Map<string, FolderTree>()
@@ -95,11 +120,6 @@ export function useKnowledgeView() {
}
async function loadDocumentsByFolder(folderId: string | null) {
if (!folderId) {
documents.value = []
return
}
isLoadingDocuments.value = true
try {
const response = await documentApi.list(folderId)
@@ -111,13 +131,34 @@ export function useKnowledgeView() {
}
}
async function persistCurrentFolder(folderId: string | null) {
if (folderId) {
localStorage.setItem(KNOWLEDGE_FOLDER_STORAGE_KEY, folderId)
} else {
localStorage.removeItem(KNOWLEDGE_FOLDER_STORAGE_KEY)
}
const nextQuery = { ...route.query }
if (folderId) {
nextQuery.folder_id = folderId
} else {
delete nextQuery.folder_id
}
await router.replace({ query: nextQuery })
}
async function enterFolder(folder: FolderTree) {
currentFolderId.value = folder.id
currentFolderName.value = folder.name
await persistCurrentFolder(folder.id)
await loadDocumentsByFolder(folder.id)
}
async function goToFolder(folderId: string | null) {
currentFolderId.value = folderId
currentFolderName.value = folderId ? (folderMap.value.get(folderId)?.name ?? '') : ''
await persistCurrentFolder(folderId)
await loadDocumentsByFolder(folderId)
}
@@ -131,6 +172,44 @@ export function useKnowledgeView() {
uploadInput.value?.click()
}
function stopStatusPolling() {
if (statusPoller.value !== null) {
window.clearInterval(statusPoller.value)
statusPoller.value = null
}
}
function getStatusLabel(status?: Document['ingestion_status'], isIndexed?: boolean) {
if (status === 'failed') return 'FAILED'
if (status === 'warning') return 'WARNING'
if (status === 'ready' || isIndexed) return 'READY'
if (status === 'indexing') return 'INDEXING'
if (status === 'parsing') return 'PARSING'
return 'UPLOADED'
}
async function refreshActiveFolder() {
await loadDocumentsByFolder(currentFolderId.value)
}
function clearUploadFeedbackLater() {
window.setTimeout(() => {
uploadSuccess.value = ''
highlightedDocumentId.value = null
}, 4000)
}
function startStatusPolling() {
stopStatusPolling()
statusPoller.value = window.setInterval(async () => {
await refreshActiveFolder()
const hasPending = documents.value.some((doc) => !['ready', 'warning', 'failed'].includes(doc.ingestion_status ?? (doc.is_indexed ? 'ready' : 'uploaded')))
if (!hasPending) {
stopStatusPolling()
}
}, 2500)
}
async function handleUpload(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
@@ -146,10 +225,49 @@ export function useKnowledgeView() {
}
isUploading.value = true
uploadError.value = ''
uploadSuccess.value = ''
highlightedDocumentId.value = null
try {
await documentApi.upload(file, currentFolderId.value)
await loadDocumentsByFolder(currentFolderId.value)
const response = await documentApi.upload(file, currentFolderId.value)
const optimisticDocument: Document = {
id: response.data.id,
title: response.data.title,
filename: file.name,
file_type: file.name.split('.').pop()?.toLowerCase() ?? '',
file_size: file.size,
chunk_count: response.data.chunk_count,
is_indexed: false,
ingestion_status: response.data.ingestion_status ?? 'uploaded',
ingestion_error: response.data.ingestion_error ?? null,
indexed_at: response.data.indexed_at ?? null,
folder_id: currentFolderId.value,
created_at: new Date().toISOString(),
}
const hasDocument = documents.value.some((doc) => doc.id === optimisticDocument.id)
if (!hasDocument) {
documents.value = [optimisticDocument, ...documents.value]
}
await refreshActiveFolder()
if (!documents.value.some((doc) => doc.id === optimisticDocument.id)) {
documents.value = [optimisticDocument, ...documents.value]
}
highlightedDocumentId.value = response.data.id
const folderName = currentFolderName.value || folderMap.value.get(currentFolderId.value ?? '')?.name || currentFolder.value?.name || '当前文件夹'
uploadSuccess.value = `已上传到 ${folderName}`
if (response.data.ingestion_error) {
uploadError.value = response.data.ingestion_error
}
clearUploadFeedbackLater()
startStatusPolling()
} catch (error) {
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail
const requestId = error.response?.data?.request_id || (error as AxiosError & { requestId?: string }).requestId
uploadError.value = [detail, requestId ? `请求ID: ${requestId}` : ''].filter(Boolean).join(' · ') || '上传失败,请稍后重试'
} else {
uploadError.value = '上传失败,请稍后重试'
}
console.error('上传失败:', error)
} finally {
isUploading.value = false
@@ -180,12 +298,13 @@ export function useKnowledgeView() {
if (!newFolderName.value.trim()) return
try {
await folderApi.create({
const response = await folderApi.create({
name: newFolderName.value.trim(),
parent_id: newFolderParentId.value,
})
await loadFolders()
showNewFolderDialog.value = false
await goToFolder(response.data.id)
} catch (error) {
console.error('创建文件夹失败:', error)
}
@@ -245,28 +364,95 @@ export function useKnowledgeView() {
}
}
async function loadDocumentChunks(documentId: string) {
isLoadingDocumentChunks.value = true
try {
const response = await documentApi.getChunks(documentId)
activeDocumentChunks.value = response.data
chunkDrafts.value = Object.fromEntries(response.data.map((chunk) => [chunk.id, chunk.content]))
chunkEditing.value = {}
chunkSaving.value = {}
chunkEditError.value = Object.fromEntries(response.data.map((chunk) => [chunk.id, '']))
} catch (error) {
console.error('加载文档切片失败:', error)
activeDocumentChunks.value = []
} finally {
isLoadingDocumentChunks.value = false
}
}
function startChunkEdit(chunk: DocumentChunk) {
chunkEditing.value[chunk.id] = true
chunkDrafts.value[chunk.id] = chunk.content
chunkEditError.value[chunk.id] = ''
}
function cancelChunkEdit(chunkId: string) {
const chunk = activeDocumentChunks.value.find((item) => item.id === chunkId)
if (!chunk) return
chunkEditing.value[chunkId] = false
chunkDrafts.value[chunkId] = chunk.content
chunkEditError.value[chunkId] = ''
}
async function saveChunkEdit(chunkId: string) {
if (!activeDocument.value) return
const nextContent = chunkDrafts.value[chunkId] ?? ''
chunkSaving.value[chunkId] = true
chunkEditError.value[chunkId] = ''
try {
const response = await documentApi.updateChunk(activeDocument.value.id, chunkId, { content: nextContent })
activeDocumentChunks.value = activeDocumentChunks.value.map((chunk) => (
chunk.id === chunkId ? response.data : chunk
))
chunkDrafts.value[chunkId] = response.data.content
chunkEditing.value[chunkId] = false
} catch (error) {
console.error('保存文档切片失败:', error)
chunkEditError.value[chunkId] = '切片保存失败,请稍后重试'
} finally {
chunkSaving.value[chunkId] = false
}
}
async function openDocument(doc: Document) {
activeDocument.value = doc
activeDocumentContent.value = ''
activeDocumentChunks.value = []
chunkDrafts.value = {}
chunkEditing.value = {}
chunkSaving.value = {}
chunkEditError.value = {}
showDocumentDialog.value = true
isLoadingDocumentContent.value = true
try {
const response = await documentApi.getContent(doc.id)
const content = response.data as string | { content?: string }
activeDocumentContent.value = typeof content === 'string' ? content : content.content ?? ''
} catch (error) {
console.error('加载文档内容失败:', error)
activeDocumentContent.value = '暂时无法加载文档内容。'
} finally {
isLoadingDocumentContent.value = false
}
const contentPromise = (async () => {
try {
const response = await documentApi.getContent(doc.id)
const content = response.data as string | { content?: string }
activeDocumentContent.value = typeof content === 'string' ? content : content.content ?? ''
} catch (error) {
console.error('加载文档内容失败:', error)
activeDocumentContent.value = '暂时无法加载文档内容。'
} finally {
isLoadingDocumentContent.value = false
}
})()
const chunkPromise = loadDocumentChunks(doc.id)
await Promise.all([contentPromise, chunkPromise])
}
function closeDocumentDialog() {
showDocumentDialog.value = false
activeDocument.value = null
activeDocumentContent.value = ''
activeDocumentChunks.value = []
chunkDrafts.value = {}
chunkEditing.value = {}
chunkSaving.value = {}
chunkEditError.value = {}
}
function getFileTypeColor(type: string) {
@@ -275,6 +461,9 @@ export function useKnowledgeView() {
md: '#60a5fa',
txt: '#34d399',
docx: '#a78bfa',
doc: '#c084fc',
csv: '#fbbf24',
xlsx: '#22c55e',
}
return colors[type] || '#9ca3af'
}
@@ -295,9 +484,22 @@ export function useKnowledgeView() {
onMounted(async () => {
await loadFolders()
const persistedFolderId = currentFolderId.value
if (persistedFolderId && folderMap.value.has(persistedFolderId)) {
currentFolderName.value = folderMap.value.get(persistedFolderId)?.name ?? ''
await loadDocumentsByFolder(persistedFolderId)
return
}
currentFolderId.value = null
currentFolderName.value = ''
await persistCurrentFolder(null)
await loadDocumentsByFolder(null)
})
onBeforeUnmount(() => {
stopStatusPolling()
})
return {
folders,
documents,
@@ -305,6 +507,8 @@ export function useKnowledgeView() {
isUploading,
isLoadingDocuments,
uploadError,
uploadSuccess,
highlightedDocumentId,
uploadInput,
showNewFolderDialog,
newFolderName,
@@ -318,6 +522,12 @@ export function useKnowledgeView() {
activeDocument,
activeDocumentContent,
isLoadingDocumentContent,
activeDocumentChunks,
isLoadingDocumentChunks,
chunkDrafts,
chunkEditing,
chunkSaving,
chunkEditError,
currentFolder,
isRoot,
visibleFolders,
@@ -337,8 +547,12 @@ export function useKnowledgeView() {
deleteFolder,
openDocument,
closeDocumentDialog,
startChunkEdit,
cancelChunkEdit,
saveChunkEdit,
getFileTypeColor,
formatFileSize,
formatDate,
getStatusLabel,
}
}

15
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
},
})