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:
1519
frontend/package-lock.json
generated
1519
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
15
frontend/vitest.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user